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.
@@ -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 };