@lhi/tdd-audit 1.4.0 → 1.4.1
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/lib/scanner.js +309 -0
- package/package.json +2 -1
package/lib/scanner.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
// ─── Vulnerability Patterns ───────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const VULN_PATTERNS = [
|
|
9
|
+
{ name: 'SQL Injection', severity: 'CRITICAL', pattern: /(`SELECT[^`]*\$\{|"SELECT[^"]*"\s*\+|execute\(f"|cursor\.execute\(.*%s|\.query\(`[^`]*\$\{)/i },
|
|
10
|
+
{ name: 'Command Injection', severity: 'CRITICAL', pattern: /\bexec(Sync)?\s*\(.*req\.(params|body|query)|subprocess\.(run|Popen|call)\([^)]*shell\s*=\s*True/i },
|
|
11
|
+
{ name: 'IDOR', severity: 'HIGH', pattern: /findById\s*\(\s*req\.(params|body|query)\.|findOne\s*\(\s*\{[^}]*id\s*:\s*req\.(params|body|query)/i },
|
|
12
|
+
{ name: 'XSS', severity: 'HIGH', pattern: /[^/]innerHTML\s*=(?!=)|dangerouslySetInnerHTML\s*=\s*\{\{|document\.write\s*\(|res\.send\s*\(`[^`]*\$\{req\./i },
|
|
13
|
+
{ name: 'Path Traversal', severity: 'HIGH', pattern: /(readFile|sendFile|createReadStream|open)\s*\(.*req\.(params|body|query)|path\.join\s*\([^)]*req\.(params|body|query)/i },
|
|
14
|
+
{ name: 'Broken Auth', severity: 'HIGH', pattern: /jwt\.decode\s*\((?![^;]*\.verify)|verify\s*:\s*false|secret\s*=\s*['"][a-z0-9]{1,20}['"]/i },
|
|
15
|
+
// Vibecoding / mobile stacks
|
|
16
|
+
{ name: 'Sensitive Storage', severity: 'HIGH', pattern: /(localStorage|AsyncStorage)\.setItem\s*\(\s*['"](token|password|secret|auth|jwt|api.?key)['"]/i },
|
|
17
|
+
{ name: 'TLS Bypass', severity: 'CRITICAL', pattern: /badCertificateCallback[^;]*=\s*true|rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['"]?0/i },
|
|
18
|
+
{ name: 'Hardcoded Secret', severity: 'CRITICAL', skipInTests: true, pattern: /(?:const|final|var|let|static)\s+(?:API_KEY|PRIVATE_KEY|SECRET_KEY|ACCESS_TOKEN|CLIENT_SECRET)\s*=\s*['"][A-Za-z0-9+/=_\-]{20,}['"]/i },
|
|
19
|
+
{ name: 'eval() Injection', severity: 'HIGH', pattern: /\beval\s*\([^)]*(?:route\.params|searchParams\.get|req\.(query|body)|params\[)/i },
|
|
20
|
+
// Common vibecoding anti-patterns
|
|
21
|
+
{ name: 'Insecure Random', severity: 'HIGH', pattern: /(?:token|sessionId|nonce|secret|csrf)\w*\s*=.*Math\.random\(\)|Math\.random\(\).*(?:token|session|nonce|secret)/i },
|
|
22
|
+
{ name: 'Sensitive Log', severity: 'MEDIUM', skipInTests: true, pattern: /console\.(log|info|debug)\([^)]*(?:token|password|secret|jwt|authorization|apiKey|api_key)/i },
|
|
23
|
+
{ name: 'Secret Fallback', severity: 'HIGH', pattern: /process\.env\.\w+\s*\|\|\s*['"][A-Za-z0-9+/=_\-]{10,}['"]/i },
|
|
24
|
+
// SSRF, redirects, injection
|
|
25
|
+
{ name: 'SSRF', severity: 'CRITICAL', pattern: /\b(?:fetch|axios\.(?:get|post|put|patch|delete|request)|got|https?\.get)\s*\(\s*req\.(?:query|body|params)\./i },
|
|
26
|
+
{ name: 'Open Redirect', severity: 'HIGH', pattern: /res\.redirect\s*\(\s*req\.(?:query|body|params)\.|window\.location(?:\.href)?\s*=\s*(?:params\.|route\.params\.|searchParams\.get)/i },
|
|
27
|
+
{ name: 'NoSQL Injection', severity: 'HIGH', pattern: /\.(?:find|findOne|findById|updateOne|deleteOne)\s*\(\s*req\.(?:body|query|params)\b|\$where\s*:\s*['"`]/i },
|
|
28
|
+
{ name: 'Template Injection', severity: 'HIGH', pattern: /res\.render\s*\(\s*req\.(?:params|body|query)\.|(?:ejs|pug|nunjucks|handlebars)\.render(?:File)?\s*\([^)]*req\.(?:body|params|query)/i },
|
|
29
|
+
{ name: 'Insecure Deserialization',severity: 'CRITICAL', pattern: /\.unserialize\s*\(.*req\.|__proto__\s*[=:][^=]|Object\.setPrototypeOf\s*\([^,]+,\s*req\./i },
|
|
30
|
+
// Assignment / pollution
|
|
31
|
+
{ name: 'Mass Assignment', severity: 'HIGH', pattern: /new\s+\w+\s*\(\s*req\.body\b|\.create\s*\(\s*req\.body\b|\.update(?:One)?\s*\(\s*\{[^}]*\},\s*req\.body\b/i },
|
|
32
|
+
{ name: 'Prototype Pollution', severity: 'HIGH', pattern: /(?:_\.merge|lodash\.merge|deepmerge|hoek\.merge)\s*\([^)]*req\.(?:body|query|params)/i },
|
|
33
|
+
// Crypto / config
|
|
34
|
+
{ name: 'Weak Crypto', severity: 'HIGH', pattern: /createHash\s*\(\s*['"](?:md5|sha1)['"]\)|(?:md5|sha1)\s*\(\s*(?:password|passwd|pwd|secret)/i },
|
|
35
|
+
{ name: 'CORS Wildcard', severity: 'MEDIUM', pattern: /cors\s*\(\s*\{\s*origin\s*:\s*['"]?\*['"]?|['"]Access-Control-Allow-Origin['"]\s*,\s*['"]?\*/i },
|
|
36
|
+
{ name: 'Cleartext Traffic', severity: 'MEDIUM', skipInTests: true, pattern: /(?:baseURL|apiUrl|API_URL|endpoint|baseUrl)\s*[:=]\s*['"]http:\/\/(?!localhost|127\.0\.0\.1)/i },
|
|
37
|
+
{ name: 'XXE', severity: 'HIGH', pattern: /noent\s*:\s*true|expand_entities\s*=\s*True|resolve_entities\s*=\s*True/i },
|
|
38
|
+
// Mobile / WebView
|
|
39
|
+
{ name: 'WebView JS Bridge', severity: 'HIGH', pattern: /addJavascriptInterface\s*\(|javaScriptEnabled\s*:\s*true|allowFileAccess\s*:\s*true|allowUniversalAccessFromFileURLs\s*:\s*true/i },
|
|
40
|
+
{ name: 'Deep Link Injection', severity: 'MEDIUM', pattern: /Linking\.getInitialURL\s*\(\)|Linking\.addEventListener\s*\(\s*['"]url['"]/i },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.py', '.go', '.dart']);
|
|
44
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', '__pycache__', 'venv', '.venv', 'vendor', '.expo', '.dart_tool', '.pub-cache']);
|
|
45
|
+
|
|
46
|
+
// ─── Framework Detection ──────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Detect the test framework used in the given project directory.
|
|
50
|
+
* @param {string} dir - absolute path to the project root
|
|
51
|
+
* @returns {'flutter'|'vitest'|'jest'|'mocha'|'pytest'|'go'}
|
|
52
|
+
*/
|
|
53
|
+
function detectFramework(dir) {
|
|
54
|
+
// Flutter / Dart — check before package.json since a Flutter project may have both
|
|
55
|
+
if (fs.existsSync(path.join(dir, 'pubspec.yaml'))) return 'flutter';
|
|
56
|
+
|
|
57
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
58
|
+
if (fs.existsSync(pkgPath)) {
|
|
59
|
+
try {
|
|
60
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
61
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
62
|
+
if (deps.vitest) return 'vitest';
|
|
63
|
+
if (deps.jest || deps.supertest) return 'jest';
|
|
64
|
+
if (deps.mocha) return 'mocha';
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
fs.existsSync(path.join(dir, 'pytest.ini')) ||
|
|
69
|
+
fs.existsSync(path.join(dir, 'pyproject.toml')) ||
|
|
70
|
+
fs.existsSync(path.join(dir, 'setup.py')) ||
|
|
71
|
+
fs.existsSync(path.join(dir, 'requirements.txt'))
|
|
72
|
+
) return 'pytest';
|
|
73
|
+
if (fs.existsSync(path.join(dir, 'go.mod'))) return 'go';
|
|
74
|
+
return 'jest';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Detect the UI/app framework used in the given project directory.
|
|
79
|
+
* @param {string} dir - absolute path to the project root
|
|
80
|
+
* @returns {'flutter'|'expo'|'react-native'|'nextjs'|'react'|null}
|
|
81
|
+
*/
|
|
82
|
+
function detectAppFramework(dir) {
|
|
83
|
+
if (fs.existsSync(path.join(dir, 'pubspec.yaml'))) return 'flutter';
|
|
84
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
85
|
+
if (fs.existsSync(pkgPath)) {
|
|
86
|
+
try {
|
|
87
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
88
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
89
|
+
if (deps.expo) return 'expo';
|
|
90
|
+
if (deps['react-native']) return 'react-native';
|
|
91
|
+
if (deps.next) return 'nextjs';
|
|
92
|
+
if (deps.react) return 'react';
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Test Directory Detection ─────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Detect the test base directory convention used in the given project.
|
|
102
|
+
* @param {string} dir - absolute path to the project root
|
|
103
|
+
* @param {string} framework - test framework (from detectFramework)
|
|
104
|
+
* @returns {string} - relative directory name, e.g. '__tests__'
|
|
105
|
+
*/
|
|
106
|
+
function detectTestBaseDir(dir, framework) {
|
|
107
|
+
const candidates = ['__tests__', 'tests', 'test', 'spec'];
|
|
108
|
+
for (const candidate of candidates) {
|
|
109
|
+
if (fs.existsSync(path.join(dir, candidate))) return candidate;
|
|
110
|
+
}
|
|
111
|
+
if (framework === 'pytest') return 'tests';
|
|
112
|
+
if (framework === 'go') return 'test';
|
|
113
|
+
return '__tests__';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── File Walking ─────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Generator that yields all scannable file paths under dir, skipping
|
|
120
|
+
* known noise dirs and symlinks (to avoid escaping the project root).
|
|
121
|
+
* @param {string} dir - directory to walk
|
|
122
|
+
*/
|
|
123
|
+
function* walkFiles(dir) {
|
|
124
|
+
let entries;
|
|
125
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
128
|
+
// Skip symlinks — they can escape the project root (M2 fix)
|
|
129
|
+
if (entry.isSymbolicLink()) continue;
|
|
130
|
+
const fullPath = path.join(dir, entry.name);
|
|
131
|
+
if (entry.isDirectory()) yield* walkFiles(fullPath);
|
|
132
|
+
else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) yield fullPath;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Test-file detection ──────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Returns true if the file is a test/spec file.
|
|
140
|
+
* @param {string} filePath - absolute path
|
|
141
|
+
* @param {string} projectDir - absolute project root (used for relative path calc)
|
|
142
|
+
*/
|
|
143
|
+
function isTestFile(filePath, projectDir) {
|
|
144
|
+
const rel = path.relative(projectDir, filePath).replace(/\\/g, '/');
|
|
145
|
+
return (
|
|
146
|
+
/[._-]test\.[a-z]+$/.test(rel) || // *.test.js / *.test.ts
|
|
147
|
+
/[._-]spec\.[a-z]+$/.test(rel) || // *.spec.js / *.spec.ts
|
|
148
|
+
/_test\.dart$/.test(rel) || // *_test.dart (Flutter)
|
|
149
|
+
/(^|\/)(__tests__|tests?)\//.test(rel) || // __tests__/ or tests/ at any depth
|
|
150
|
+
/(^|\/)spec\//.test(rel) || // spec/ at any depth
|
|
151
|
+
/(^|\/)test_/.test(rel) // test_helpers.js style
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── Config / Manifest Scanners ───────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Scan app.json / app.config.* for embedded secrets.
|
|
159
|
+
* @param {string} projectDir - project root
|
|
160
|
+
* @returns {Array}
|
|
161
|
+
*/
|
|
162
|
+
function scanAppConfig(projectDir) {
|
|
163
|
+
const findings = [];
|
|
164
|
+
const configCandidates = ['app.json', 'app.config.js', 'app.config.ts'];
|
|
165
|
+
// Match quoted string values AND template-literal fallback secrets (L2 fix)
|
|
166
|
+
const secretPattern = /['"]?(?:apiKey|api_key|secret|privateKey|accessToken|clientSecret)['"]?\s*[:=]\s*(?:['"][A-Za-z0-9+/=_\-]{20,}['"]|`[^`]*['"][A-Za-z0-9+/=_\-]{10,}['"][^`]*`)/i;
|
|
167
|
+
|
|
168
|
+
for (const name of configCandidates) {
|
|
169
|
+
const filePath = path.join(projectDir, name);
|
|
170
|
+
if (!fs.existsSync(filePath)) continue;
|
|
171
|
+
let lines;
|
|
172
|
+
try { lines = fs.readFileSync(filePath, 'utf8').split('\n'); } catch { continue; }
|
|
173
|
+
for (let i = 0; i < lines.length; i++) {
|
|
174
|
+
if (secretPattern.test(lines[i])) {
|
|
175
|
+
findings.push({
|
|
176
|
+
severity: 'CRITICAL',
|
|
177
|
+
name: 'Config Secret',
|
|
178
|
+
file: name,
|
|
179
|
+
line: i + 1,
|
|
180
|
+
snippet: lines[i].trim().slice(0, 80),
|
|
181
|
+
inTestFile: false,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Scan AndroidManifest.xml for android:debuggable="true".
|
|
191
|
+
* @param {string} projectDir - project root
|
|
192
|
+
* @returns {Array}
|
|
193
|
+
*/
|
|
194
|
+
function scanAndroidManifest(projectDir) {
|
|
195
|
+
const findings = [];
|
|
196
|
+
const manifestPath = path.join(projectDir, 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
|
|
197
|
+
if (!fs.existsSync(manifestPath)) return findings;
|
|
198
|
+
let lines;
|
|
199
|
+
try { lines = fs.readFileSync(manifestPath, 'utf8').split('\n'); } catch { return findings; }
|
|
200
|
+
for (let i = 0; i < lines.length; i++) {
|
|
201
|
+
if (/android:debuggable\s*=\s*["']true["']/i.test(lines[i])) {
|
|
202
|
+
findings.push({
|
|
203
|
+
severity: 'HIGH',
|
|
204
|
+
name: 'Android Debuggable',
|
|
205
|
+
file: 'android/app/src/main/AndroidManifest.xml',
|
|
206
|
+
line: i + 1,
|
|
207
|
+
snippet: lines[i].trim().slice(0, 80),
|
|
208
|
+
inTestFile: false,
|
|
209
|
+
likelyFalsePositive: false,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return findings;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Quick Scan ───────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Scan all source files in projectDir for known vulnerability patterns.
|
|
220
|
+
* @param {string} projectDir - project root to scan
|
|
221
|
+
* @returns {Array} findings
|
|
222
|
+
*/
|
|
223
|
+
function quickScan(projectDir) {
|
|
224
|
+
const findings = [];
|
|
225
|
+
for (const filePath of walkFiles(projectDir)) {
|
|
226
|
+
const inTest = isTestFile(filePath, projectDir);
|
|
227
|
+
let content;
|
|
228
|
+
// L1 fix: guard against binary / non-UTF-8 files
|
|
229
|
+
try {
|
|
230
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// Skip files that contain null bytes — likely binary
|
|
235
|
+
if (content.includes('\0')) continue;
|
|
236
|
+
|
|
237
|
+
const lines = content.split('\n');
|
|
238
|
+
for (let i = 0; i < lines.length; i++) {
|
|
239
|
+
// M3 fix: collect ALL matching patterns per line (no break)
|
|
240
|
+
for (const vuln of VULN_PATTERNS) {
|
|
241
|
+
if (vuln.pattern.test(lines[i])) {
|
|
242
|
+
findings.push({
|
|
243
|
+
severity: vuln.severity,
|
|
244
|
+
name: vuln.name,
|
|
245
|
+
file: path.relative(projectDir, filePath),
|
|
246
|
+
line: i + 1,
|
|
247
|
+
snippet: lines[i].trim().slice(0, 80),
|
|
248
|
+
inTestFile: inTest,
|
|
249
|
+
likelyFalsePositive: inTest && !!vuln.skipInTests,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return [...findings, ...scanAppConfig(projectDir), ...scanAndroidManifest(projectDir)];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Print Findings ───────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Print a human-readable findings report to stdout.
|
|
262
|
+
* @param {Array} findings
|
|
263
|
+
*/
|
|
264
|
+
function printFindings(findings) {
|
|
265
|
+
if (findings.length === 0) {
|
|
266
|
+
console.log(' ✅ No obvious vulnerability patterns detected.\n');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const real = findings.filter(f => !f.likelyFalsePositive);
|
|
270
|
+
const noisy = findings.filter(f => f.likelyFalsePositive);
|
|
271
|
+
|
|
272
|
+
const bySeverity = { CRITICAL: [], HIGH: [], MEDIUM: [], LOW: [] };
|
|
273
|
+
for (const f of real) (bySeverity[f.severity] || bySeverity.LOW).push(f);
|
|
274
|
+
const icons = { CRITICAL: '🔴', HIGH: '🟠', MEDIUM: '🟡', LOW: '🔵' };
|
|
275
|
+
|
|
276
|
+
console.log(`\n Found ${real.length} potential issue(s)${noisy.length ? ` (+${noisy.length} in test files — see below)` : ''}:\n`);
|
|
277
|
+
for (const [sev, list] of Object.entries(bySeverity)) {
|
|
278
|
+
if (!list.length) continue;
|
|
279
|
+
for (const f of list) {
|
|
280
|
+
const testBadge = f.inTestFile ? ' [test file]' : '';
|
|
281
|
+
console.log(` ${icons[sev]} [${sev}] ${f.name} — ${f.file}:${f.line}${testBadge}`);
|
|
282
|
+
console.log(` ${f.snippet}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (noisy.length) {
|
|
287
|
+
console.log('\n ⚪ Likely intentional (in test files — verify manually):');
|
|
288
|
+
for (const f of noisy) {
|
|
289
|
+
console.log(` ${f.name} — ${f.file}:${f.line}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log('\n Run /tdd-audit in your agent to remediate.\n');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
VULN_PATTERNS,
|
|
298
|
+
SCAN_EXTENSIONS,
|
|
299
|
+
SKIP_DIRS,
|
|
300
|
+
detectFramework,
|
|
301
|
+
detectAppFramework,
|
|
302
|
+
detectTestBaseDir,
|
|
303
|
+
walkFiles,
|
|
304
|
+
isTestFile,
|
|
305
|
+
scanAppConfig,
|
|
306
|
+
scanAndroidManifest,
|
|
307
|
+
quickScan,
|
|
308
|
+
printFindings,
|
|
309
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lhi/tdd-audit",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Anti-Gravity Skill for TDD Remediation. Patches security vulnerabilities using a Red-Green-Refactor protocol with automated exploit tests.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"index.js",
|
|
11
|
+
"lib/",
|
|
11
12
|
"SKILL.md",
|
|
12
13
|
"prompts/",
|
|
13
14
|
"templates/",
|