mpx-scan 1.0.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/LICENSE +32 -0
- package/README.md +277 -0
- package/bin/cli.js +211 -0
- package/package.json +45 -0
- package/src/generators/fixes.js +256 -0
- package/src/index.js +153 -0
- package/src/license.js +187 -0
- package/src/reporters/json.js +32 -0
- package/src/reporters/terminal.js +140 -0
- package/src/scanners/cookies.js +122 -0
- package/src/scanners/dns.js +113 -0
- package/src/scanners/exposed-files.js +231 -0
- package/src/scanners/fingerprint.js +325 -0
- package/src/scanners/headers.js +203 -0
- package/src/scanners/mixed-content.js +109 -0
- package/src/scanners/redirects.js +120 -0
- package/src/scanners/server.js +146 -0
- package/src/scanners/sri.js +162 -0
- package/src/scanners/ssl.js +160 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates copy-paste configuration snippets for fixing security issues
|
|
5
|
+
* Supports: nginx, apache, caddy, cloudflare
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const chalk = require('chalk');
|
|
9
|
+
|
|
10
|
+
const PLATFORMS = ['nginx', 'apache', 'caddy', 'cloudflare'];
|
|
11
|
+
|
|
12
|
+
function generateFixes(platform, results) {
|
|
13
|
+
if (!PLATFORMS.includes(platform)) {
|
|
14
|
+
throw new Error(`Unknown platform: ${platform}. Supported: ${PLATFORMS.join(', ')}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const lines = [];
|
|
18
|
+
|
|
19
|
+
lines.push('');
|
|
20
|
+
lines.push(chalk.bold.cyan('═'.repeat(70)));
|
|
21
|
+
lines.push(chalk.bold.cyan(` Security Fix Configuration — ${platform.toUpperCase()}`));
|
|
22
|
+
lines.push(chalk.bold.cyan('═'.repeat(70)));
|
|
23
|
+
lines.push('');
|
|
24
|
+
|
|
25
|
+
// Collect all failed/warned checks with recommendations
|
|
26
|
+
const issues = [];
|
|
27
|
+
|
|
28
|
+
for (const [sectionName, section] of Object.entries(results.sections)) {
|
|
29
|
+
for (const check of section.checks) {
|
|
30
|
+
if ((check.status === 'fail' || check.status === 'warn') && check.recommendation) {
|
|
31
|
+
issues.push({ section: sectionName, check });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (issues.length === 0) {
|
|
37
|
+
lines.push(chalk.green('✓ No security issues found! Your site is well-configured.'));
|
|
38
|
+
lines.push('');
|
|
39
|
+
return lines.join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
lines.push(chalk.yellow(`Found ${issues.length} issue(s) to fix:\n`));
|
|
43
|
+
|
|
44
|
+
// Generate platform-specific configs
|
|
45
|
+
switch (platform) {
|
|
46
|
+
case 'nginx':
|
|
47
|
+
lines.push(...generateNginxConfig(issues));
|
|
48
|
+
break;
|
|
49
|
+
case 'apache':
|
|
50
|
+
lines.push(...generateApacheConfig(issues));
|
|
51
|
+
break;
|
|
52
|
+
case 'caddy':
|
|
53
|
+
lines.push(...generateCaddyConfig(issues));
|
|
54
|
+
break;
|
|
55
|
+
case 'cloudflare':
|
|
56
|
+
lines.push(...generateCloudflareConfig(issues));
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lines.push('');
|
|
61
|
+
lines.push(chalk.gray('─'.repeat(70)));
|
|
62
|
+
lines.push(chalk.gray('After applying these changes:'));
|
|
63
|
+
lines.push(chalk.gray('1. Test your configuration'));
|
|
64
|
+
lines.push(chalk.gray(`2. Reload/restart your ${platform} server`));
|
|
65
|
+
lines.push(chalk.gray('3. Run mpx-scan again to verify'));
|
|
66
|
+
lines.push(chalk.gray('─'.repeat(70)));
|
|
67
|
+
lines.push('');
|
|
68
|
+
|
|
69
|
+
return lines.join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function generateNginxConfig(issues) {
|
|
73
|
+
const lines = [];
|
|
74
|
+
|
|
75
|
+
lines.push(chalk.bold('Add these headers to your nginx config:'));
|
|
76
|
+
lines.push(chalk.gray('# Location: /etc/nginx/sites-available/your-site'));
|
|
77
|
+
lines.push(chalk.gray('# Inside the server {} block:\n'));
|
|
78
|
+
|
|
79
|
+
lines.push(chalk.cyan('server {'));
|
|
80
|
+
lines.push(chalk.cyan(' # ... your existing config ...\n'));
|
|
81
|
+
|
|
82
|
+
const headers = extractHeaders(issues);
|
|
83
|
+
|
|
84
|
+
for (const [header, value] of Object.entries(headers)) {
|
|
85
|
+
lines.push(chalk.green(` add_header ${header} "${value}" always;`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// SSL-specific recommendations
|
|
89
|
+
if (hasSSLIssues(issues)) {
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push(chalk.gray(' # SSL/TLS Configuration'));
|
|
92
|
+
lines.push(chalk.green(' ssl_protocols TLSv1.2 TLSv1.3;'));
|
|
93
|
+
lines.push(chalk.green(' ssl_prefer_server_ciphers on;'));
|
|
94
|
+
lines.push(chalk.green(' ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384";'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
lines.push(chalk.cyan('}'));
|
|
98
|
+
lines.push('');
|
|
99
|
+
lines.push(chalk.gray('# Then reload nginx:'));
|
|
100
|
+
lines.push(chalk.yellow('sudo nginx -t && sudo systemctl reload nginx'));
|
|
101
|
+
lines.push('');
|
|
102
|
+
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function generateApacheConfig(issues) {
|
|
107
|
+
const lines = [];
|
|
108
|
+
|
|
109
|
+
lines.push(chalk.bold('Add these headers to your Apache config:'));
|
|
110
|
+
lines.push(chalk.gray('# Location: /etc/apache2/sites-available/your-site.conf'));
|
|
111
|
+
lines.push(chalk.gray('# Or .htaccess in your document root\n'));
|
|
112
|
+
|
|
113
|
+
lines.push(chalk.cyan('<IfModule mod_headers.c>'));
|
|
114
|
+
|
|
115
|
+
const headers = extractHeaders(issues);
|
|
116
|
+
|
|
117
|
+
for (const [header, value] of Object.entries(headers)) {
|
|
118
|
+
lines.push(chalk.green(` Header always set ${header} "${value}"`));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
lines.push(chalk.cyan('</IfModule>'));
|
|
122
|
+
lines.push('');
|
|
123
|
+
|
|
124
|
+
if (hasSSLIssues(issues)) {
|
|
125
|
+
lines.push(chalk.gray('# SSL/TLS Configuration'));
|
|
126
|
+
lines.push(chalk.cyan('<IfModule mod_ssl.c>'));
|
|
127
|
+
lines.push(chalk.green(' SSLProtocol -all +TLSv1.2 +TLSv1.3'));
|
|
128
|
+
lines.push(chalk.green(' SSLCipherSuite HIGH:!aNULL:!MD5'));
|
|
129
|
+
lines.push(chalk.green(' SSLHonorCipherOrder on'));
|
|
130
|
+
lines.push(chalk.cyan('</IfModule>'));
|
|
131
|
+
lines.push('');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push(chalk.gray('# Then reload Apache:'));
|
|
135
|
+
lines.push(chalk.yellow('sudo apachectl configtest && sudo systemctl reload apache2'));
|
|
136
|
+
lines.push('');
|
|
137
|
+
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function generateCaddyConfig(issues) {
|
|
142
|
+
const lines = [];
|
|
143
|
+
|
|
144
|
+
lines.push(chalk.bold('Add these headers to your Caddyfile:'));
|
|
145
|
+
lines.push(chalk.gray('# Location: /etc/caddy/Caddyfile\n'));
|
|
146
|
+
|
|
147
|
+
lines.push(chalk.cyan('your-domain.com {'));
|
|
148
|
+
lines.push(chalk.gray(' # ... your existing config ...\n'));
|
|
149
|
+
|
|
150
|
+
const headers = extractHeaders(issues);
|
|
151
|
+
|
|
152
|
+
lines.push(chalk.green(' header {'));
|
|
153
|
+
for (const [header, value] of Object.entries(headers)) {
|
|
154
|
+
lines.push(chalk.green(` ${header} "${value}"`));
|
|
155
|
+
}
|
|
156
|
+
lines.push(chalk.green(' }'));
|
|
157
|
+
|
|
158
|
+
lines.push(chalk.cyan('}'));
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push(chalk.gray('# Caddy automatically handles TLS 1.2/1.3'));
|
|
161
|
+
lines.push(chalk.gray('# Then reload Caddy:'));
|
|
162
|
+
lines.push(chalk.yellow('sudo systemctl reload caddy'));
|
|
163
|
+
lines.push('');
|
|
164
|
+
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function generateCloudflareConfig(issues) {
|
|
169
|
+
const lines = [];
|
|
170
|
+
|
|
171
|
+
lines.push(chalk.bold('Configure Cloudflare security headers:'));
|
|
172
|
+
lines.push(chalk.gray('Dashboard → Rules → Transform Rules → Modify Response Header\n'));
|
|
173
|
+
|
|
174
|
+
const headers = extractHeaders(issues);
|
|
175
|
+
|
|
176
|
+
lines.push(chalk.yellow('Create these Transform Rules:'));
|
|
177
|
+
lines.push('');
|
|
178
|
+
|
|
179
|
+
for (const [header, value] of Object.entries(headers)) {
|
|
180
|
+
lines.push(chalk.green(`• Set "${header}" to "${value}"`));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
lines.push('');
|
|
184
|
+
lines.push(chalk.gray('Or use Cloudflare Workers:'));
|
|
185
|
+
lines.push('');
|
|
186
|
+
lines.push(chalk.cyan('addEventListener("fetch", event => {'));
|
|
187
|
+
lines.push(chalk.cyan(' event.respondWith(handleRequest(event.request));'));
|
|
188
|
+
lines.push(chalk.cyan('});'));
|
|
189
|
+
lines.push('');
|
|
190
|
+
lines.push(chalk.cyan('async function handleRequest(request) {'));
|
|
191
|
+
lines.push(chalk.cyan(' const response = await fetch(request);'));
|
|
192
|
+
lines.push(chalk.cyan(' const newHeaders = new Headers(response.headers);'));
|
|
193
|
+
lines.push('');
|
|
194
|
+
|
|
195
|
+
for (const [header, value] of Object.entries(headers)) {
|
|
196
|
+
lines.push(chalk.green(` newHeaders.set("${header}", "${value}");`));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
lines.push('');
|
|
200
|
+
lines.push(chalk.cyan(' return new Response(response.body, {'));
|
|
201
|
+
lines.push(chalk.cyan(' status: response.status,'));
|
|
202
|
+
lines.push(chalk.cyan(' statusText: response.statusText,'));
|
|
203
|
+
lines.push(chalk.cyan(' headers: newHeaders'));
|
|
204
|
+
lines.push(chalk.cyan(' });'));
|
|
205
|
+
lines.push(chalk.cyan('}'));
|
|
206
|
+
lines.push('');
|
|
207
|
+
|
|
208
|
+
if (hasSSLIssues(issues)) {
|
|
209
|
+
lines.push(chalk.gray('# Also check:'));
|
|
210
|
+
lines.push(chalk.yellow('• SSL/TLS → Edge Certificates → Minimum TLS Version → TLS 1.2'));
|
|
211
|
+
lines.push('');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return lines;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function extractHeaders(issues) {
|
|
218
|
+
const headers = {};
|
|
219
|
+
|
|
220
|
+
for (const { check } of issues) {
|
|
221
|
+
const rec = check.recommendation;
|
|
222
|
+
|
|
223
|
+
if (rec.includes('Strict-Transport-Security')) {
|
|
224
|
+
headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload';
|
|
225
|
+
}
|
|
226
|
+
if (rec.includes('Content-Security-Policy')) {
|
|
227
|
+
headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'";
|
|
228
|
+
}
|
|
229
|
+
if (rec.includes('X-Content-Type-Options')) {
|
|
230
|
+
headers['X-Content-Type-Options'] = 'nosniff';
|
|
231
|
+
}
|
|
232
|
+
if (rec.includes('X-Frame-Options')) {
|
|
233
|
+
headers['X-Frame-Options'] = 'DENY';
|
|
234
|
+
}
|
|
235
|
+
if (rec.includes('Referrer-Policy')) {
|
|
236
|
+
headers['Referrer-Policy'] = 'strict-origin-when-cross-origin';
|
|
237
|
+
}
|
|
238
|
+
if (rec.includes('Permissions-Policy')) {
|
|
239
|
+
headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()';
|
|
240
|
+
}
|
|
241
|
+
if (rec.includes('Cross-Origin-Opener-Policy')) {
|
|
242
|
+
headers['Cross-Origin-Opener-Policy'] = 'same-origin';
|
|
243
|
+
}
|
|
244
|
+
if (rec.includes('Cross-Origin-Resource-Policy')) {
|
|
245
|
+
headers['Cross-Origin-Resource-Policy'] = 'same-origin';
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return headers;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function hasSSLIssues(issues) {
|
|
253
|
+
return issues.some(({ section }) => section === 'ssl');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { generateFixes, PLATFORMS };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mpx-scan — Professional Website Security Scanner
|
|
3
|
+
*
|
|
4
|
+
* Core engine. Runs all security checks against a target URL
|
|
5
|
+
* and returns a structured report with grades and recommendations.
|
|
6
|
+
*
|
|
7
|
+
* Zero external dependencies for scanning (only chalk/commander for CLI)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { scanHeaders } = require('./scanners/headers');
|
|
11
|
+
const { scanSSL } = require('./scanners/ssl');
|
|
12
|
+
const { scanCookies } = require('./scanners/cookies');
|
|
13
|
+
const { scanExposedFiles } = require('./scanners/exposed-files');
|
|
14
|
+
const { scanDNS } = require('./scanners/dns');
|
|
15
|
+
const { scanServer } = require('./scanners/server');
|
|
16
|
+
const { scanSRI } = require('./scanners/sri');
|
|
17
|
+
const { scanMixedContent } = require('./scanners/mixed-content');
|
|
18
|
+
const { scanRedirects } = require('./scanners/redirects');
|
|
19
|
+
|
|
20
|
+
const GRADES = ['A+', 'A', 'B', 'C', 'D', 'F'];
|
|
21
|
+
|
|
22
|
+
const SCANNER_TIERS = {
|
|
23
|
+
free: ['headers', 'ssl', 'server'],
|
|
24
|
+
pro: ['headers', 'ssl', 'cookies', 'server', 'exposedFiles', 'dns', 'sri', 'mixedContent', 'redirects']
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run a full security scan on the target URL
|
|
29
|
+
* @param {string} url - Target URL to scan
|
|
30
|
+
* @param {object} options - Scan options
|
|
31
|
+
* @returns {object} Structured scan report
|
|
32
|
+
*/
|
|
33
|
+
async function scan(url, options = {}) {
|
|
34
|
+
const startTime = Date.now();
|
|
35
|
+
|
|
36
|
+
// Normalize URL
|
|
37
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
38
|
+
url = 'https://' + url;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parsedUrl = new URL(url);
|
|
42
|
+
const results = {
|
|
43
|
+
url: parsedUrl.href,
|
|
44
|
+
hostname: parsedUrl.hostname,
|
|
45
|
+
scannedAt: new Date().toISOString(),
|
|
46
|
+
scanDuration: 0,
|
|
47
|
+
grade: 'F',
|
|
48
|
+
score: 0,
|
|
49
|
+
maxScore: 0,
|
|
50
|
+
sections: {},
|
|
51
|
+
summary: {
|
|
52
|
+
passed: 0,
|
|
53
|
+
warnings: 0,
|
|
54
|
+
failed: 0,
|
|
55
|
+
info: 0
|
|
56
|
+
},
|
|
57
|
+
tier: options.tier || 'free'
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const allScanners = [
|
|
61
|
+
{ name: 'headers', fn: scanHeaders, weight: 25 },
|
|
62
|
+
{ name: 'ssl', fn: scanSSL, weight: 20 },
|
|
63
|
+
{ name: 'cookies', fn: scanCookies, weight: 10 },
|
|
64
|
+
{ name: 'server', fn: scanServer, weight: 8 },
|
|
65
|
+
{ name: 'exposedFiles', fn: scanExposedFiles, weight: 10 },
|
|
66
|
+
{ name: 'dns', fn: scanDNS, weight: 7 },
|
|
67
|
+
{ name: 'sri', fn: scanSRI, weight: 5 },
|
|
68
|
+
{ name: 'mixedContent', fn: scanMixedContent, weight: 5 },
|
|
69
|
+
{ name: 'redirects', fn: scanRedirects, weight: 5 },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
// Determine which scanners to run based on tier and options
|
|
73
|
+
const tier = options.tier || 'free';
|
|
74
|
+
const allowedScanners = options.full ? SCANNER_TIERS.pro : (SCANNER_TIERS[tier] || SCANNER_TIERS.free);
|
|
75
|
+
|
|
76
|
+
const enabledScanners = allScanners.filter(s => allowedScanners.includes(s.name));
|
|
77
|
+
|
|
78
|
+
// Run scanners concurrently
|
|
79
|
+
const scanPromises = enabledScanners.map(async (scanner) => {
|
|
80
|
+
try {
|
|
81
|
+
const result = await scanner.fn(parsedUrl, options);
|
|
82
|
+
return { name: scanner.name, weight: scanner.weight, result };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return {
|
|
85
|
+
name: scanner.name,
|
|
86
|
+
weight: scanner.weight,
|
|
87
|
+
result: {
|
|
88
|
+
score: 0,
|
|
89
|
+
maxScore: scanner.weight,
|
|
90
|
+
checks: [{
|
|
91
|
+
name: `${scanner.name} scan`,
|
|
92
|
+
status: 'error',
|
|
93
|
+
message: err.message
|
|
94
|
+
}]
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const scanResults = await Promise.all(scanPromises);
|
|
101
|
+
|
|
102
|
+
// Aggregate results
|
|
103
|
+
let totalScore = 0;
|
|
104
|
+
let totalMax = 0;
|
|
105
|
+
|
|
106
|
+
for (const { name, weight, result } of scanResults) {
|
|
107
|
+
// Normalize scores to weight
|
|
108
|
+
const normalizedScore = result.maxScore > 0
|
|
109
|
+
? (result.score / result.maxScore) * weight
|
|
110
|
+
: 0;
|
|
111
|
+
|
|
112
|
+
totalScore += normalizedScore;
|
|
113
|
+
totalMax += weight;
|
|
114
|
+
|
|
115
|
+
results.sections[name] = {
|
|
116
|
+
score: Math.round(normalizedScore * 10) / 10,
|
|
117
|
+
maxScore: weight,
|
|
118
|
+
grade: calculateGrade(normalizedScore / weight),
|
|
119
|
+
checks: result.checks || []
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Count statuses
|
|
123
|
+
for (const check of (result.checks || [])) {
|
|
124
|
+
if (check.status === 'pass') results.summary.passed++;
|
|
125
|
+
else if (check.status === 'warn') results.summary.warnings++;
|
|
126
|
+
else if (check.status === 'fail') results.summary.failed++;
|
|
127
|
+
else if (check.status === 'info') results.summary.info++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
results.score = Math.round(totalScore * 10) / 10;
|
|
132
|
+
results.maxScore = totalMax;
|
|
133
|
+
results.grade = calculateGrade(totalScore / totalMax);
|
|
134
|
+
results.scanDuration = Date.now() - startTime;
|
|
135
|
+
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function calculateGrade(ratio) {
|
|
140
|
+
if (ratio >= 0.95) return 'A+';
|
|
141
|
+
if (ratio >= 0.85) return 'A';
|
|
142
|
+
if (ratio >= 0.70) return 'B';
|
|
143
|
+
if (ratio >= 0.55) return 'C';
|
|
144
|
+
if (ratio >= 0.40) return 'D';
|
|
145
|
+
return 'F';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function scoreToPercentage(score, maxScore) {
|
|
149
|
+
if (maxScore === 0) return 0;
|
|
150
|
+
return Math.round((score / maxScore) * 100);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { scan, calculateGrade, scoreToPercentage, SCANNER_TIERS };
|
package/src/license.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* License Management
|
|
3
|
+
*
|
|
4
|
+
* Free tier: 3 scans/day, basic checks only
|
|
5
|
+
* Pro tier: unlimited scans, all checks, JSON export
|
|
6
|
+
*
|
|
7
|
+
* Simple file-based license tracking (can be upgraded to LemonSqueezy API later)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
const LICENSE_DIR = path.join(os.homedir(), '.mpx-scan');
|
|
15
|
+
const LICENSE_FILE = path.join(LICENSE_DIR, 'license.json');
|
|
16
|
+
const USAGE_FILE = path.join(LICENSE_DIR, 'usage.json');
|
|
17
|
+
|
|
18
|
+
const FREE_DAILY_LIMIT = 3;
|
|
19
|
+
|
|
20
|
+
function ensureDir() {
|
|
21
|
+
if (!fs.existsSync(LICENSE_DIR)) {
|
|
22
|
+
fs.mkdirSync(LICENSE_DIR, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get current license status
|
|
28
|
+
* @returns {object} { tier: 'free'|'pro', key: string|null }
|
|
29
|
+
*/
|
|
30
|
+
function getLicense() {
|
|
31
|
+
ensureDir();
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
if (fs.existsSync(LICENSE_FILE)) {
|
|
35
|
+
const data = JSON.parse(fs.readFileSync(LICENSE_FILE, 'utf8'));
|
|
36
|
+
|
|
37
|
+
// Validate license key format (simple check for now)
|
|
38
|
+
if (data.key && data.key.startsWith('MPX-PRO-')) {
|
|
39
|
+
return { tier: 'pro', key: data.key, email: data.email || null };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// Invalid license file, treat as free
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { tier: 'free', key: null, email: null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Activate a pro license
|
|
51
|
+
* @param {string} key - License key
|
|
52
|
+
* @param {string} email - User email (optional)
|
|
53
|
+
*/
|
|
54
|
+
function activateLicense(key, email = null) {
|
|
55
|
+
ensureDir();
|
|
56
|
+
|
|
57
|
+
// TODO: Validate with LemonSqueezy API
|
|
58
|
+
// For now, just check format
|
|
59
|
+
if (!key.startsWith('MPX-PRO-')) {
|
|
60
|
+
throw new Error('Invalid license key format. Pro keys start with MPX-PRO-');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const licenseData = {
|
|
64
|
+
key,
|
|
65
|
+
email,
|
|
66
|
+
activatedAt: new Date().toISOString()
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
fs.writeFileSync(LICENSE_FILE, JSON.stringify(licenseData, null, 2));
|
|
70
|
+
|
|
71
|
+
return { success: true, tier: 'pro' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if user can perform a scan (rate limiting for free tier)
|
|
76
|
+
* @returns {object} { allowed: boolean, remaining: number, resetsAt: string }
|
|
77
|
+
*/
|
|
78
|
+
function checkRateLimit() {
|
|
79
|
+
const license = getLicense();
|
|
80
|
+
|
|
81
|
+
// Pro users have unlimited scans
|
|
82
|
+
if (license.tier === 'pro') {
|
|
83
|
+
return { allowed: true, remaining: -1, resetsAt: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
ensureDir();
|
|
87
|
+
|
|
88
|
+
// Free tier: check daily usage
|
|
89
|
+
let usage = { scans: [], lastReset: null };
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (fs.existsSync(USAGE_FILE)) {
|
|
93
|
+
usage = JSON.parse(fs.readFileSync(USAGE_FILE, 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Invalid usage file, reset
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const now = new Date();
|
|
100
|
+
const today = now.toISOString().split('T')[0];
|
|
101
|
+
|
|
102
|
+
// Reset if it's a new day
|
|
103
|
+
if (usage.lastReset !== today) {
|
|
104
|
+
usage = { scans: [], lastReset: today };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Filter scans from today
|
|
108
|
+
const todayScans = usage.scans.filter(s => s.startsWith(today));
|
|
109
|
+
|
|
110
|
+
if (todayScans.length >= FREE_DAILY_LIMIT) {
|
|
111
|
+
const tomorrow = new Date(now);
|
|
112
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
113
|
+
tomorrow.setHours(0, 0, 0, 0);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
allowed: false,
|
|
117
|
+
remaining: 0,
|
|
118
|
+
resetsAt: tomorrow.toISOString()
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
allowed: true,
|
|
124
|
+
remaining: FREE_DAILY_LIMIT - todayScans.length,
|
|
125
|
+
resetsAt: null
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Record a scan (for free tier rate limiting)
|
|
131
|
+
*/
|
|
132
|
+
function recordScan() {
|
|
133
|
+
const license = getLicense();
|
|
134
|
+
|
|
135
|
+
// No need to track pro scans
|
|
136
|
+
if (license.tier === 'pro') {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
ensureDir();
|
|
141
|
+
|
|
142
|
+
let usage = { scans: [], lastReset: null };
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
if (fs.existsSync(USAGE_FILE)) {
|
|
146
|
+
usage = JSON.parse(fs.readFileSync(USAGE_FILE, 'utf8'));
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// Invalid usage file, reset
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const now = new Date();
|
|
153
|
+
const today = now.toISOString().split('T')[0];
|
|
154
|
+
|
|
155
|
+
// Reset if it's a new day
|
|
156
|
+
if (usage.lastReset !== today) {
|
|
157
|
+
usage = { scans: [], lastReset: today };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
usage.scans.push(now.toISOString());
|
|
161
|
+
|
|
162
|
+
// Keep only last 10 scans for privacy
|
|
163
|
+
if (usage.scans.length > 10) {
|
|
164
|
+
usage.scans = usage.scans.slice(-10);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
fs.writeFileSync(USAGE_FILE, JSON.stringify(usage, null, 2));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Deactivate license
|
|
172
|
+
*/
|
|
173
|
+
function deactivateLicense() {
|
|
174
|
+
if (fs.existsSync(LICENSE_FILE)) {
|
|
175
|
+
fs.unlinkSync(LICENSE_FILE);
|
|
176
|
+
}
|
|
177
|
+
return { success: true, tier: 'free' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
getLicense,
|
|
182
|
+
activateLicense,
|
|
183
|
+
deactivateLicense,
|
|
184
|
+
checkRateLimit,
|
|
185
|
+
recordScan,
|
|
186
|
+
FREE_DAILY_LIMIT
|
|
187
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Reporter
|
|
3
|
+
*
|
|
4
|
+
* Machine-readable output for CI/CD pipelines
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
function formatJSON(results, pretty = false) {
|
|
8
|
+
const output = {
|
|
9
|
+
mpxScan: {
|
|
10
|
+
version: '1.0.0',
|
|
11
|
+
scannedAt: results.scannedAt,
|
|
12
|
+
scanDuration: results.scanDuration
|
|
13
|
+
},
|
|
14
|
+
target: {
|
|
15
|
+
url: results.url,
|
|
16
|
+
hostname: results.hostname
|
|
17
|
+
},
|
|
18
|
+
score: {
|
|
19
|
+
grade: results.grade,
|
|
20
|
+
numeric: results.score,
|
|
21
|
+
maxScore: results.maxScore,
|
|
22
|
+
percentage: Math.round((results.score / results.maxScore) * 100)
|
|
23
|
+
},
|
|
24
|
+
summary: results.summary,
|
|
25
|
+
sections: results.sections,
|
|
26
|
+
tier: results.tier
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return JSON.stringify(output, null, pretty ? 2 : 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { formatJSON };
|