@sun-asterisk/sunlint 1.3.16 → 1.3.17

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.
Files changed (50) hide show
  1. package/config/rule-analysis-strategies.js +3 -3
  2. package/config/rules/enhanced-rules-registry.json +40 -20
  3. package/core/cli-action-handler.js +2 -2
  4. package/core/config-merger.js +28 -6
  5. package/core/constants/defaults.js +1 -1
  6. package/core/file-targeting-service.js +72 -4
  7. package/core/output-service.js +21 -4
  8. package/engines/heuristic-engine.js +5 -0
  9. package/package.json +1 -1
  10. package/rules/common/C002_no_duplicate_code/README.md +115 -0
  11. package/rules/common/C002_no_duplicate_code/analyzer.js +615 -219
  12. package/rules/common/C002_no_duplicate_code/test-cases/api-handlers.ts +64 -0
  13. package/rules/common/C002_no_duplicate_code/test-cases/data-processor.ts +46 -0
  14. package/rules/common/C002_no_duplicate_code/test-cases/good-example.tsx +40 -0
  15. package/rules/common/C002_no_duplicate_code/test-cases/product-service.ts +57 -0
  16. package/rules/common/C002_no_duplicate_code/test-cases/user-service.ts +49 -0
  17. package/rules/common/C008/analyzer.js +40 -0
  18. package/rules/common/C008/config.json +20 -0
  19. package/rules/common/C008/ts-morph-analyzer.js +1067 -0
  20. package/rules/common/C018_no_throw_generic_error/analyzer.js +1 -1
  21. package/rules/common/C018_no_throw_generic_error/symbol-based-analyzer.js +27 -3
  22. package/rules/common/C024_no_scatter_hardcoded_constants/symbol-based-analyzer.js +504 -162
  23. package/rules/common/C029_catch_block_logging/analyzer.js +499 -89
  24. package/rules/common/C033_separate_service_repository/README.md +131 -20
  25. package/rules/common/C033_separate_service_repository/analyzer.js +1 -1
  26. package/rules/common/C033_separate_service_repository/symbol-based-analyzer.js +417 -274
  27. package/rules/common/C041_no_sensitive_hardcode/analyzer.js +144 -254
  28. package/rules/common/C041_no_sensitive_hardcode/config.json +50 -0
  29. package/rules/common/C041_no_sensitive_hardcode/symbol-based-analyzer.js +575 -0
  30. package/rules/common/C067_no_hardcoded_config/analyzer.js +17 -16
  31. package/rules/common/C067_no_hardcoded_config/symbol-based-analyzer.js +3477 -659
  32. package/rules/docs/C002_no_duplicate_code.md +276 -11
  33. package/rules/index.js +5 -1
  34. package/rules/security/S006_no_plaintext_recovery_codes/analyzer.js +266 -88
  35. package/rules/security/S006_no_plaintext_recovery_codes/symbol-based-analyzer.js +805 -0
  36. package/rules/security/S010_no_insecure_encryption/README.md +78 -0
  37. package/rules/security/S010_no_insecure_encryption/analyzer.js +463 -398
  38. package/rules/security/S013_tls_enforcement/README.md +51 -0
  39. package/rules/security/S013_tls_enforcement/analyzer.js +99 -0
  40. package/rules/security/S013_tls_enforcement/config.json +41 -0
  41. package/rules/security/S013_tls_enforcement/symbol-based-analyzer.js +339 -0
  42. package/rules/security/S014_tls_version_enforcement/README.md +354 -0
  43. package/rules/security/S014_tls_version_enforcement/analyzer.js +118 -0
  44. package/rules/security/S014_tls_version_enforcement/config.json +56 -0
  45. package/rules/security/S014_tls_version_enforcement/symbol-based-analyzer.js +194 -0
  46. package/rules/security/S055_content_type_validation/analyzer.js +121 -279
  47. package/rules/security/S055_content_type_validation/symbol-based-analyzer.js +346 -0
  48. package/rules/tests/C002_no_duplicate_code.test.js +111 -22
  49. package/rules/common/C029_catch_block_logging/analyzer-smart-pipeline.js +0 -755
  50. package/rules/common/C041_no_sensitive_hardcode/ast-analyzer.js +0 -296
@@ -1,292 +1,182 @@
1
- const fs = require('fs');
2
- const path = require('path');
1
+ /**
2
+ * C041 Main Analyzer - Do not hardcode or push sensitive information
3
+ * Primary: Protect sensitive application data, avoid security risks, and comply with security standards. Exposing sensitive information can lead to serious security and privacy issues.
4
+ * Fallback: Regex-based for all other cases
5
+ * Purpose: Detect hardcoded sensitive information in source code
6
+ * Command: node cli.js --rules=C041 --input=examples/rule-test-fixtures/rules/C041_no_sensitive_hardcode --engine=heuristic --verbose
7
+ */
8
+
9
+ const C041SymbolBasedAnalyzer = require('./symbol-based-analyzer');
3
10
 
4
11
  class C041Analyzer {
5
- constructor() {
12
+ constructor(options = {}) {
13
+ if (process.env.SUNLINT_DEBUG) {
14
+ console.log(`🔧 [C041] Constructor called with options:`, !!options);
15
+ console.log(`🔧 [C041] Options type:`, typeof options, Object.keys(options || {}));
16
+ }
17
+
6
18
  this.ruleId = 'C041';
7
- this.ruleName = 'No Hardcoded Sensitive Information';
8
- this.description = 'Không hardcode hoặc push thông tin nhạy cảm vào repo';
9
- }
19
+ this.ruleName = 'Do not hardcode or push sensitive information';
20
+ this.description = 'Protect sensitive application data, avoid security risks, and comply with security standards. Exposing sensitive information can lead to serious security and privacy issues.';
21
+ this.semanticEngine = options.semanticEngine || null;
22
+ this.verbose = options.verbose || false;
23
+
24
+ // Configuration
25
+ this.config = {
26
+ useSymbolBased: true, // Primary approach
27
+ fallbackToRegex: false, // Only when symbol fails completely
28
+ symbolBasedOnly: false // Can be set to true for pure mode
29
+ };
10
30
 
11
- async analyze(files, language, options = {}) {
12
- const violations = [];
13
-
14
- for (const filePath of files) {
15
- if (options.verbose) {
16
- console.log(`🔍 Running C041 analysis on ${path.basename(filePath)}`);
17
- }
18
-
19
- try {
20
- const content = fs.readFileSync(filePath, 'utf8');
21
- const fileViolations = await this.analyzeFile(filePath, content, language, options);
22
- violations.push(...fileViolations);
23
- } catch (error) {
24
- console.warn(`⚠️ Failed to analyze ${filePath}: ${error.message}`);
31
+ // Initialize both analyzers
32
+ try {
33
+ this.symbolAnalyzer = new C041SymbolBasedAnalyzer(this.semanticEngine);
34
+ if (process.env.SUNLINT_DEBUG) {
35
+ console.log(`🔧 [C041] Symbol analyzer created successfully`);
25
36
  }
37
+ } catch (error) {
38
+ console.error(`🔧 [C041] Error creating symbol analyzer:`, error);
26
39
  }
27
-
28
- return violations;
29
40
  }
30
41
 
31
- async analyzeFile(filePath, content, language, config) {
32
- switch (language) {
33
- case 'typescript':
34
- case 'javascript':
35
- return this.analyzeTypeScript(filePath, content, config);
36
- default:
37
- return [];
42
+ /**
43
+ * Initialize with semantic engine
44
+ */
45
+ async initialize(semanticEngine = null) {
46
+ if (semanticEngine) {
47
+ this.semanticEngine = semanticEngine;
48
+ }
49
+ this.verbose = semanticEngine?.verbose || false;
50
+
51
+ // Initialize both analyzers
52
+ await this.symbolAnalyzer.initialize(semanticEngine);
53
+
54
+ // Ensure verbose flag is propagated
55
+ this.symbolAnalyzer.verbose = this.verbose;
56
+
57
+ if (this.verbose) {
58
+ console.log(`🔧 [C041 Hybrid] Analyzer initialized - verbose: ${this.verbose}`);
38
59
  }
39
60
  }
40
61
 
41
- async analyzeTypeScript(filePath, content, config) {
62
+ async analyze(files, language, options = {}) {
63
+ if (process.env.SUNLINT_DEBUG) {
64
+ console.log(`🔧 [C041] analyze() method called with ${files.length} files, language: ${language}`);
65
+ }
66
+
42
67
  const violations = [];
43
- const lines = content.split('\n');
44
68
 
45
- lines.forEach((line, index) => {
46
- const lineNumber = index + 1;
47
- const trimmedLine = line.trim();
69
+ for (const filePath of files) {
70
+ try {
71
+ if (process.env.SUNLINT_DEBUG) {
72
+ console.log(`🔧 [C041] Processing file: ${filePath}`);
73
+ }
74
+
75
+ const fileViolations = await this.analyzeFile(filePath, options);
76
+ violations.push(...fileViolations);
48
77
 
49
- // Skip comments and imports
50
- if (this.isCommentOrImport(trimmedLine)) {
51
- return;
78
+ if (process.env.SUNLINT_DEBUG) {
79
+ console.log(`🔧 [C041] File ${filePath}: Found ${fileViolations.length} violations`);
80
+ }
81
+ } catch (error) {
82
+ console.warn(`❌ [C041] Analysis failed for ${filePath}:`, error.message);
52
83
  }
84
+ }
53
85
 
54
- // Find potential hardcoded sensitive values
55
- const sensitiveMatches = this.findSensitiveHardcode(trimmedLine, line);
56
-
57
- sensitiveMatches.forEach(match => {
58
- violations.push({
59
- ruleId: this.ruleId,
60
- file: filePath,
61
- line: lineNumber,
62
- column: match.column,
63
- message: match.message,
64
- severity: 'error',
65
- code: trimmedLine,
66
- type: match.type,
67
- confidence: match.confidence,
68
- suggestion: match.suggestion
69
- });
70
- });
71
- });
86
+ if (process.env.SUNLINT_DEBUG) {
87
+ console.log(`🔧 [C041] Total violations found: ${violations.length}`);
88
+ }
72
89
 
73
90
  return violations;
74
91
  }
75
92
 
76
- isCommentOrImport(line) {
77
- const trimmed = line.trim();
78
- return trimmed.startsWith('//') ||
79
- trimmed.startsWith('/*') ||
80
- trimmed.startsWith('*') ||
81
- trimmed.startsWith('import ') ||
82
- trimmed.startsWith('export ');
83
- }
84
-
85
- findSensitiveHardcode(line, originalLine) {
86
- const matches = [];
87
-
88
- // Skip template literals with variables - they are dynamic, not hardcoded
89
- if (line.includes('${') || line.includes('`')) {
90
- return matches;
91
- }
92
-
93
- // Skip if line is clearly configuration, type definition, or UI-related
94
- if (this.isConfigOrUIContext(line)) {
95
- return matches;
93
+ async analyzeFile(filePath, options = {}) {
94
+ if (process.env.SUNLINT_DEBUG) {
95
+ console.log(`🔧 [C041] analyzeFile() called for: ${filePath}`);
96
96
  }
97
-
98
- // Look for suspicious patterns with better context awareness
99
- const patterns = [
100
- {
101
- name: 'suspicious_password_variable',
102
- regex: /(const|let|var)\s+\w*[Pp]ass[Ww]ord\w*\s*=\s*['"`]([^'"`]{4,})['"`]/g,
103
- severity: 'error',
104
- message: 'Potential hardcoded password in variable assignment',
105
- suggestion: 'Move sensitive values to environment variables or secure config files'
106
- },
107
- {
108
- name: 'suspicious_secret_variable',
109
- regex: /(const|let|var)\s+\w*[Ss]ecret\w*\s*=\s*['"`]([^'"`]{6,})['"`]/g,
110
- severity: 'error',
111
- message: 'Potential hardcoded secret in variable assignment',
112
- suggestion: 'Use environment variables for secrets'
113
- },
114
- {
115
- name: 'suspicious_short_password',
116
- regex: /(const|let|var)\s+(?!use)\w*([Pp]ass|[Dd]b[Pp]ass|[Aa]dmin)(?!word[A-Z])\w*\s*=\s*['"`]([^'"`]{4,})['"`]/g,
117
- severity: 'error',
118
- message: 'Potential hardcoded password or admin credential',
119
- suggestion: 'Use environment variables for credentials'
120
- },
121
- {
122
- name: 'api_key',
123
- regex: /(const|let|var)\s+\w*[Aa]pi[Kk]ey\w*\s*=\s*['"`]([^'"`]{10,})['"`]/g,
124
- severity: 'error',
125
- message: 'Potential hardcoded API key detected',
126
- suggestion: 'Use environment variables for API keys'
127
- },
128
- {
129
- name: 'auth_token',
130
- regex: /(const|let|var)\s+\w*[Tt]oken\w*\s*=\s*['"`]([^'"`]{16,})['"`]/g,
131
- severity: 'error',
132
- message: 'Potential hardcoded authentication token detected',
133
- suggestion: 'Store tokens in secure storage, not in source code'
134
- },
135
- {
136
- name: 'database_url',
137
- regex: /['"`](mongodb|mysql|postgres|redis):\/\/[^'"`]+['"`]/gi,
138
- severity: 'error',
139
- message: 'Hardcoded database connection string detected',
140
- suggestion: 'Use environment variables for database connections'
141
- },
142
- {
143
- name: 'suspicious_url',
144
- regex: /['"`]https?:\/\/(?!localhost|127\.0\.0\.1|example\.com|test\.com|www\.w3\.org|www\.google\.com|googleapis\.com)[^'"`]{20,}['"`]/gi,
145
- severity: 'warning',
146
- message: 'Hardcoded external URL detected (consider configuration)',
147
- suggestion: 'Consider moving URLs to configuration files'
148
- }
149
- ];
150
97
 
151
- // Additional context-aware checks
152
- patterns.forEach(pattern => {
153
- let match;
154
- while ((match = pattern.regex.exec(line)) !== null) {
155
- // Skip false positives
156
- if (this.isFalsePositive(line, match[0], pattern.name)) {
157
- continue;
98
+ // 1. Try Symbol-based analysis first (primary)
99
+ if (this.config.useSymbolBased &&
100
+ this.semanticEngine?.project &&
101
+ this.semanticEngine?.initialized) {
102
+ try {
103
+ if (process.env.SUNLINT_DEBUG) {
104
+ console.log(`🔧 [C041] Trying symbol-based analysis...`);
158
105
  }
159
-
160
- matches.push({
161
- type: pattern.name,
162
- column: match.index + 1,
163
- message: pattern.message,
164
- confidence: this.calculateConfidence(line, match[0], pattern.name),
165
- suggestion: pattern.suggestion
166
- });
106
+ const sourceFile = this.semanticEngine.project.getSourceFile(filePath);
107
+ if (sourceFile) {
108
+ if (process.env.SUNLINT_DEBUG) {
109
+ console.log(`🔧 [C041] Source file found, analyzing with symbol-based...`);
110
+ }
111
+ const violations = await this.symbolAnalyzer.analyzeFileWithSymbols(filePath, { ...options, verbose: options.verbose });
112
+
113
+ // Mark violations with analysis strategy
114
+ violations.forEach(v => v.analysisStrategy = 'symbol-based');
115
+
116
+ if (process.env.SUNLINT_DEBUG) {
117
+ console.log(`✅ [C041] Symbol-based analysis: ${violations.length} violations`);
118
+ }
119
+ return violations; // Return even if 0 violations - symbol analysis completed successfully
120
+ } else {
121
+ if (process.env.SUNLINT_DEBUG) {
122
+ console.log(`⚠️ [C041] Source file not found in project`);
123
+ }
124
+ }
125
+ } catch (error) {
126
+ console.warn(`⚠️ [C041] Symbol analysis failed: ${error.message}`);
127
+ // Continue to fallback
167
128
  }
168
- });
169
-
170
- return matches;
171
- }
129
+ } else {
130
+ if (process.env.SUNLINT_DEBUG) {
131
+ console.log(`🔄 [C041] Symbol analysis conditions check:`);
132
+ console.log(` - useSymbolBased: ${this.config.useSymbolBased}`);
133
+ console.log(` - semanticEngine: ${!!this.semanticEngine}`);
134
+ console.log(` - semanticEngine.project: ${!!this.semanticEngine?.project}`);
135
+ console.log(` - semanticEngine.initialized: ${this.semanticEngine?.initialized}`);
136
+ console.log(`🔄 [C041] Symbol analysis unavailable, using regex fallback`);
137
+ }
138
+ }
172
139
 
173
- isConfigOrUIContext(line) {
174
- const lowerLine = line.toLowerCase();
175
-
176
- // UI/Component contexts - likely false positives
177
- const uiContexts = [
178
- 'inputtype', 'type:', 'type =', 'type:', 'inputtype=',
179
- 'routes =', 'route:', 'path:', 'routes:',
180
- 'import {', 'export {', 'from ', 'import ',
181
- 'interface', 'type ', 'enum ',
182
- 'props:', 'defaultprops',
183
- 'schema', 'validator',
184
- 'hook', 'use', 'const use', 'import.*use',
185
- // React/UI specific
186
- 'textinput', 'input ', 'field ', 'form',
187
- 'component', 'page', 'screen', 'modal',
188
- // Route/navigation specific
189
- 'navigation', 'route', 'path', 'url:', 'route:',
190
- 'setuppassword', 'resetpassword', 'forgotpassword',
191
- 'changepassword', 'confirmpassword'
192
- ];
193
-
194
- return uiContexts.some(context => lowerLine.includes(context));
140
+ if (options?.verbose) {
141
+ console.log(`🔧 [C041] No analysis methods succeeded, returning empty`);
142
+ }
143
+ return [];
195
144
  }
196
145
 
197
- isFalsePositive(line, matchedText, patternName) {
198
- const lowerLine = line.toLowerCase();
199
- const lowerMatch = matchedText.toLowerCase();
146
+ async analyzeFileBasic(filePath, options = {}) {
147
+ console.log(`🔧 [C041] analyzeFileBasic() called for: ${filePath}`);
148
+ console.log(`🔧 [C041] semanticEngine exists: ${!!this.semanticEngine}`);
149
+ console.log(`🔧 [C041] symbolAnalyzer exists: ${!!this.symbolAnalyzer}`);
200
150
 
201
- // Global false positive indicators
202
- const globalFalsePositives = [
203
- 'test', 'mock', 'example', 'demo', 'sample', 'placeholder', 'dummy', 'fake',
204
- 'xmlns', 'namespace', 'schema', 'w3.org', 'google.com', 'googleapis.com',
205
- 'error', 'message', 'missing', 'invalid', 'failed'
206
- ];
151
+ try {
152
+ // Try symbol-based analysis first
153
+ if (this.semanticEngine?.isSymbolEngineReady?.() &&
154
+ this.semanticEngine.project) {
207
155
 
208
- // Check if the line contains any global false positive indicators
209
- const hasGlobalFalsePositive = globalFalsePositives.some(pattern =>
210
- lowerLine.includes(pattern) || lowerMatch.includes(pattern)
211
- );
212
-
213
- if (hasGlobalFalsePositive) {
214
- return true;
215
- }
216
-
217
- // Common false positive patterns
218
- const falsePositivePatterns = {
219
- 'suspicious_password_variable': [
220
- 'inputtype', 'type:', 'type =', 'activation', 'forgot_password', 'reset_password',
221
- 'setup_password', 'route', 'path', 'hook', 'use', 'change', 'confirm',
222
- 'validation', 'component', 'page', 'screen', 'textinput', 'input',
223
- 'trigger', 'useeffect', 'password.*trigger', 'renewpassword'
224
- ],
225
- 'suspicious_short_password': [
226
- 'inputtype', 'type:', 'type =', 'activation', 'forgot_password', 'reset_password',
227
- 'setup_password', 'route', 'path', 'hook', 'use', 'change', 'confirm',
228
- 'validation', 'component', 'page', 'screen', 'textinput'
229
- ],
230
- 'suspicious_secret_variable': [
231
- 'component', 'props', 'state', 'hook', 'use'
232
- ],
233
- 'suspicious_url': [
234
- 'localhost', '127.0.0.1', 'example.com', 'test.com', 'placeholder',
235
- 'mock', 'w3.org', 'google.com', 'recaptcha', 'googleapis.com'
236
- ],
237
- 'api_key': [
238
- 'test-', 'mock-', 'example-', 'demo-', 'missing', 'error', 'message'
239
- ]
240
- };
241
-
242
- const patterns = falsePositivePatterns[patternName] || [];
243
-
244
- // Check if line contains any pattern-specific false positive indicators
245
- const hasPatternFalsePositive = patterns.some(pattern =>
246
- lowerLine.includes(pattern) || lowerMatch.includes(pattern)
247
- );
156
+ if (this.verbose) {
157
+ console.log(`🔍 [C041] Using symbol-based analysis for ${filePath}`);
158
+ }
248
159
 
249
- // Special handling for password-related patterns
250
- if (patternName === 'hardcoded_password') {
251
- // Allow if it's clearly UI/component related
252
- if (lowerLine.includes('input') ||
253
- lowerLine.includes('field') ||
254
- lowerLine.includes('form') ||
255
- lowerLine.includes('component') ||
256
- lowerLine.includes('type') ||
257
- lowerLine.includes('route') ||
258
- lowerLine.includes('path') ||
259
- lowerMatch.includes('activation') ||
260
- lowerMatch.includes('forgot_password') ||
261
- lowerMatch.includes('reset_password') ||
262
- lowerMatch.includes('setup_password')) {
263
- return true;
160
+ const violations = await this.symbolAnalyzer.analyzeFileBasic(filePath, options);
161
+ return violations;
162
+ }
163
+ } catch (error) {
164
+ if (this.verbose) {
165
+ console.warn(`⚠️ [C041] Symbol analysis failed: ${error.message}`);
264
166
  }
265
167
  }
266
-
267
- return hasPatternFalsePositive;
268
168
  }
269
169
 
270
- calculateConfidence(line, match, patternName) {
271
- let confidence = 0.8; // Base confidence
272
-
273
- // Reduce confidence for potential false positives
274
- const lowerLine = line.toLowerCase();
275
-
276
- if (lowerLine.includes('test') || lowerLine.includes('mock') || lowerLine.includes('example')) {
277
- confidence -= 0.3;
278
- }
279
-
280
- if (lowerLine.includes('const') || lowerLine.includes('let') || lowerLine.includes('var')) {
281
- confidence += 0.1; // Variable assignments more likely to be hardcode
282
- }
283
-
284
- if (lowerLine.includes('type') || lowerLine.includes('component') || lowerLine.includes('props')) {
285
- confidence -= 0.2; // UI-related less likely to be sensitive
286
- }
170
+ /**
171
+ * Methods for compatibility with different engine invocation patterns
172
+ */
173
+ async analyzeFileWithSymbols(filePath, options = {}) {
174
+ return this.analyzeFile(filePath, options);
175
+ }
287
176
 
288
- return Math.max(0.3, Math.min(1.0, confidence));
177
+ async analyzeWithSemantics(filePath, options = {}) {
178
+ return this.analyzeFile(filePath, options);
289
179
  }
290
180
  }
291
181
 
292
- module.exports = new C041Analyzer();
182
+ module.exports = C041Analyzer;
@@ -0,0 +1,50 @@
1
+ {
2
+ "id": "C041",
3
+ "name": "C041_do_not_hardcode_or_push_sensitive_information",
4
+ "category": "architecture",
5
+ "description": "C041 - Do not hardcode or push sensitive information (token, API key, secret, URL) into the repo",
6
+ "severity": "warning",
7
+ "enabled": true,
8
+ "semantic": {
9
+ "enabled": true,
10
+ "priority": "high",
11
+ "fallback": "heuristic"
12
+ },
13
+ "patterns": {
14
+ "include": [
15
+ "**/*.js",
16
+ "**/*.ts",
17
+ "**/*.jsx",
18
+ "**/*.tsx"
19
+ ],
20
+ "exclude": [
21
+ "**/*.test.*",
22
+ "**/*.spec.*",
23
+ "**/*.mock.*",
24
+ "**/test/**",
25
+ "**/tests/**",
26
+ "**/spec/**"
27
+ ]
28
+ },
29
+ "options": {
30
+ "strictMode": false,
31
+ "allowedDbMethods": [],
32
+ "repositoryPatterns": [
33
+ "*Repository*",
34
+ "*Repo*",
35
+ "*DAO*",
36
+ "*Store*"
37
+ ],
38
+ "servicePatterns": [
39
+ "*Service*",
40
+ "*UseCase*",
41
+ "*Handler*",
42
+ "*Manager*"
43
+ ],
44
+ "complexityThreshold": {
45
+ "methodLength": 200,
46
+ "cyclomaticComplexity": 5,
47
+ "nestedDepth": 3
48
+ }
49
+ }
50
+ }