@soulbatical/tetra-dev-toolkit 1.20.19 → 1.20.21
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/checks/codeQuality/dangerously-set-inner-html.js +61 -0
- package/lib/checks/security/csp-disabled.js +65 -0
- package/lib/checks/security/reflected-xss.js +65 -0
- package/lib/checks/security/shell-injection.js +69 -0
- package/lib/checks/security/timing-unsafe-secrets.js +76 -0
- package/lib/checks/stability/local-deps.js +91 -0
- package/package.json +1 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect dangerouslySetInnerHTML usage without sanitization.
|
|
3
|
+
* Safe alternatives: React elements, DOMPurify, or escapeHtml() from tetra-core.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { glob } from 'glob'
|
|
7
|
+
import { readFileSync } from 'fs'
|
|
8
|
+
|
|
9
|
+
export const meta = {
|
|
10
|
+
id: 'dangerously-set-inner-html',
|
|
11
|
+
name: 'dangerouslySetInnerHTML Detection',
|
|
12
|
+
category: 'codeQuality',
|
|
13
|
+
severity: 'medium',
|
|
14
|
+
description: 'Flags all dangerouslySetInnerHTML usage for XSS review'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function run(config, projectRoot) {
|
|
18
|
+
const results = {
|
|
19
|
+
passed: true,
|
|
20
|
+
findings: [],
|
|
21
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const files = await glob('**/*.{tsx,jsx}', {
|
|
25
|
+
cwd: projectRoot,
|
|
26
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/.next/**', ...config.ignore]
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const PATTERN = /dangerouslySetInnerHTML/g
|
|
30
|
+
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
if (file.includes('.test.') || file.includes('.spec.')) continue
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
|
|
36
|
+
const lines = content.split('\n')
|
|
37
|
+
|
|
38
|
+
PATTERN.lastIndex = 0
|
|
39
|
+
let match
|
|
40
|
+
while ((match = PATTERN.exec(content)) !== null) {
|
|
41
|
+
const lineNum = content.substring(0, match.index).split('\n').length
|
|
42
|
+
const lineContent = lines[lineNum - 1]?.trim() || ''
|
|
43
|
+
|
|
44
|
+
const nearbyLines = lines.slice(Math.max(0, lineNum - 4), lineNum + 2).join('\n')
|
|
45
|
+
const isSanitized = /DOMPurify|escapeHtml|sanitize/i.test(nearbyLines)
|
|
46
|
+
|
|
47
|
+
if (isSanitized) {
|
|
48
|
+
results.findings.push({ severity: 'low', file, line: lineNum, message: 'dangerouslySetInnerHTML with sanitization (review for completeness)', snippet: lineContent.substring(0, 120) })
|
|
49
|
+
results.summary.low++
|
|
50
|
+
} else {
|
|
51
|
+
results.findings.push({ severity: 'medium', file, line: lineNum, message: 'dangerouslySetInnerHTML without sanitization — XSS risk', snippet: lineContent.substring(0, 120) })
|
|
52
|
+
results.summary.medium++
|
|
53
|
+
}
|
|
54
|
+
results.summary.total++
|
|
55
|
+
}
|
|
56
|
+
} catch { /* skip unreadable */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
results.passed = results.summary.critical === 0 && results.summary.high === 0
|
|
60
|
+
return results
|
|
61
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect when Content Security Policy is explicitly disabled.
|
|
3
|
+
* CSP is a critical XSS defense and should never be turned off.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { glob } from 'glob'
|
|
7
|
+
import { readFileSync } from 'fs'
|
|
8
|
+
|
|
9
|
+
export const meta = {
|
|
10
|
+
id: 'csp-disabled',
|
|
11
|
+
name: 'CSP Enforcement',
|
|
12
|
+
category: 'security',
|
|
13
|
+
severity: 'critical',
|
|
14
|
+
description: 'Detects when Content-Security-Policy is explicitly disabled in Helmet or security config'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PATTERNS = [
|
|
18
|
+
{ name: 'CSP disabled in Helmet', pattern: /contentSecurityPolicy\s*:\s*false/g },
|
|
19
|
+
{ name: 'unsafe-eval in CSP', pattern: /['"]unsafe-eval['"]/g },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
export async function run(config, projectRoot) {
|
|
23
|
+
const results = {
|
|
24
|
+
passed: true,
|
|
25
|
+
findings: [],
|
|
26
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const files = await glob('**/*.{ts,tsx,js,jsx,toml}', {
|
|
30
|
+
cwd: projectRoot,
|
|
31
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/.next/**', ...config.ignore]
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
if (file.includes('.test.') || file.includes('.spec.')) continue
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
|
|
39
|
+
const lines = content.split('\n')
|
|
40
|
+
|
|
41
|
+
for (const { name, pattern } of PATTERNS) {
|
|
42
|
+
pattern.lastIndex = 0
|
|
43
|
+
let match
|
|
44
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
45
|
+
const lineNum = content.substring(0, match.index).split('\n').length
|
|
46
|
+
const lineContent = lines[lineNum - 1]?.trim() || ''
|
|
47
|
+
const severity = name.includes('disabled') ? 'critical' : 'high'
|
|
48
|
+
|
|
49
|
+
results.findings.push({
|
|
50
|
+
severity,
|
|
51
|
+
file,
|
|
52
|
+
line: lineNum,
|
|
53
|
+
message: `${name}: CSP should be configured, not disabled. Use CSP directives instead.`,
|
|
54
|
+
snippet: lineContent.substring(0, 120)
|
|
55
|
+
})
|
|
56
|
+
results.summary[severity]++
|
|
57
|
+
results.summary.total++
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* skip unreadable */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
results.passed = results.summary.critical === 0
|
|
64
|
+
return results
|
|
65
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect reflected XSS: request query/body/params interpolated into HTML responses
|
|
3
|
+
* without escaping. Use escapeHtml() from tetra-core before rendering.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { glob } from 'glob'
|
|
7
|
+
import { readFileSync } from 'fs'
|
|
8
|
+
|
|
9
|
+
export const meta = {
|
|
10
|
+
id: 'reflected-xss',
|
|
11
|
+
name: 'Reflected XSS Detection',
|
|
12
|
+
category: 'security',
|
|
13
|
+
severity: 'critical',
|
|
14
|
+
description: 'Detects req.query/body/params interpolated into HTML responses without escaping'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PATTERNS = [
|
|
18
|
+
{ name: 'req.query in HTML send', pattern: /res\.send\s*\(\s*[`'"][^`'"]*\$\{[^}]*req\.(query|params|body)/g },
|
|
19
|
+
{ name: 'req.query in HTML template', pattern: /`[^`]*<[^`]*\$\{[^}]*req\.(query|params|body)/g },
|
|
20
|
+
{ name: 'res.send with unescaped error', pattern: /res\.send\s*\(\s*[`'"][^`'"]*<[^`'"]*\$\{[^}]*error/g },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export async function run(config, projectRoot) {
|
|
24
|
+
const results = {
|
|
25
|
+
passed: true,
|
|
26
|
+
findings: [],
|
|
27
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const files = await glob('**/*.{ts,tsx,js,jsx}', {
|
|
31
|
+
cwd: projectRoot,
|
|
32
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/.next/**', ...config.ignore]
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
for (const file of files) {
|
|
36
|
+
if (file.includes('.test.') || file.includes('.spec.')) continue
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
|
|
40
|
+
const lines = content.split('\n')
|
|
41
|
+
|
|
42
|
+
for (const { name, pattern } of PATTERNS) {
|
|
43
|
+
pattern.lastIndex = 0
|
|
44
|
+
let match
|
|
45
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
46
|
+
const lineNum = content.substring(0, match.index).split('\n').length
|
|
47
|
+
const lineContent = lines[lineNum - 1]?.trim() || ''
|
|
48
|
+
|
|
49
|
+
results.findings.push({
|
|
50
|
+
severity: 'critical',
|
|
51
|
+
file,
|
|
52
|
+
line: lineNum,
|
|
53
|
+
message: `${name}: escape with escapeHtml() from tetra-core before rendering in HTML`,
|
|
54
|
+
snippet: lineContent.substring(0, 120)
|
|
55
|
+
})
|
|
56
|
+
results.summary.critical++
|
|
57
|
+
results.summary.total++
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch { /* skip unreadable */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
results.passed = results.summary.critical === 0
|
|
64
|
+
return results
|
|
65
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect shell injection risks: child_process calls with template literals
|
|
3
|
+
* or string concatenation instead of array-based arguments.
|
|
4
|
+
*
|
|
5
|
+
* Safe alternative: use execFileSync with array arguments,
|
|
6
|
+
* or tetra-core's safeExecCommand() utility.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { glob } from 'glob'
|
|
10
|
+
import { readFileSync } from 'fs'
|
|
11
|
+
|
|
12
|
+
export const meta = {
|
|
13
|
+
id: 'shell-injection',
|
|
14
|
+
name: 'Shell Injection Detection',
|
|
15
|
+
category: 'security',
|
|
16
|
+
severity: 'high',
|
|
17
|
+
description: 'Detects child_process calls with template literals or string interpolation (shell injection risk)'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Patterns that indicate unsafe shell command construction
|
|
21
|
+
const DANGEROUS_PATTERNS = [
|
|
22
|
+
{ name: 'child_process call with template literal', pattern: /(?:execSync|spawnSync)\s*\(\s*`[^`]*\$\{/g },
|
|
23
|
+
{ name: 'child_process call with string concat', pattern: /(?:execSync|spawnSync)\s*\([^)]*\+/g },
|
|
24
|
+
{ name: 'spawn with shell:true', pattern: /spawn\s*\([^)]*shell\s*:\s*true/g },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export async function run(config, projectRoot) {
|
|
28
|
+
const results = {
|
|
29
|
+
passed: true,
|
|
30
|
+
findings: [],
|
|
31
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const files = await glob('**/*.{ts,tsx,js,jsx}', {
|
|
35
|
+
cwd: projectRoot,
|
|
36
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/.next/**', ...config.ignore]
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
if (file.includes('.test.') || file.includes('.spec.')) continue
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
|
|
44
|
+
const lines = content.split('\n')
|
|
45
|
+
|
|
46
|
+
for (const { name, pattern } of DANGEROUS_PATTERNS) {
|
|
47
|
+
pattern.lastIndex = 0
|
|
48
|
+
let match
|
|
49
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
50
|
+
const lineNum = content.substring(0, match.index).split('\n').length
|
|
51
|
+
const lineContent = lines[lineNum - 1]?.trim() || ''
|
|
52
|
+
|
|
53
|
+
results.findings.push({
|
|
54
|
+
severity: 'high',
|
|
55
|
+
file,
|
|
56
|
+
line: lineNum,
|
|
57
|
+
message: `${name}: use execFileSync/safeExecCommand with array args instead`,
|
|
58
|
+
snippet: lineContent.substring(0, 120)
|
|
59
|
+
})
|
|
60
|
+
results.summary.high++
|
|
61
|
+
results.summary.total++
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} catch { /* skip unreadable */ }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
results.passed = results.summary.high === 0 && results.summary.critical === 0
|
|
68
|
+
return results
|
|
69
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect timing-unsafe comparisons on secrets, keys, passwords, and hashes.
|
|
3
|
+
* These should use crypto.timingSafeEqual() to prevent side-channel attacks.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { glob } from 'glob'
|
|
7
|
+
import { readFileSync } from 'fs'
|
|
8
|
+
|
|
9
|
+
export const meta = {
|
|
10
|
+
id: 'timing-unsafe-secrets',
|
|
11
|
+
name: 'Timing-Unsafe Secret Comparison',
|
|
12
|
+
category: 'security',
|
|
13
|
+
severity: 'high',
|
|
14
|
+
description: 'Detects === or !== comparisons on variables named key/secret/password/hash/token'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Match lines where a variable with secret-like name is compared with === or !==
|
|
18
|
+
const SECRET_COMPARE_PATTERN = /\b(\w*(?:key|secret|password|hash|token|apiKey|apikey|api_key)\w*)\s*(?:!==|===)\s*/gi
|
|
19
|
+
|
|
20
|
+
// Allowlist: these comparisons are safe (checking existence, not comparing values)
|
|
21
|
+
const SAFE_PATTERNS = [
|
|
22
|
+
/!==\s*(?:undefined|null|''|"")/,
|
|
23
|
+
/===\s*(?:undefined|null|''|"")/,
|
|
24
|
+
/!==\s*['"]none['"]/,
|
|
25
|
+
/\.length\s*[!=]==/,
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export async function run(config, projectRoot) {
|
|
29
|
+
const results = {
|
|
30
|
+
passed: true,
|
|
31
|
+
findings: [],
|
|
32
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const files = await glob('**/*.{ts,tsx,js,jsx}', {
|
|
36
|
+
cwd: projectRoot,
|
|
37
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/.next/**', ...config.ignore]
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
if (file.includes('.test.') || file.includes('.spec.')) continue
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
|
|
45
|
+
const lines = content.split('\n')
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
const line = lines[i]
|
|
49
|
+
SECRET_COMPARE_PATTERN.lastIndex = 0
|
|
50
|
+
const match = SECRET_COMPARE_PATTERN.exec(line)
|
|
51
|
+
if (!match) continue
|
|
52
|
+
|
|
53
|
+
// Skip safe patterns (null/undefined checks)
|
|
54
|
+
const restOfLine = line.substring(match.index)
|
|
55
|
+
if (SAFE_PATTERNS.some(p => p.test(restOfLine))) continue
|
|
56
|
+
|
|
57
|
+
// Skip comments
|
|
58
|
+
const trimmed = line.trim()
|
|
59
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*')) continue
|
|
60
|
+
|
|
61
|
+
results.findings.push({
|
|
62
|
+
severity: 'high',
|
|
63
|
+
file,
|
|
64
|
+
line: i + 1,
|
|
65
|
+
message: `Timing-unsafe comparison on '${match[1]}': use crypto.timingSafeEqual() instead`,
|
|
66
|
+
snippet: trimmed.substring(0, 120)
|
|
67
|
+
})
|
|
68
|
+
results.summary.high++
|
|
69
|
+
results.summary.total++
|
|
70
|
+
}
|
|
71
|
+
} catch { /* skip unreadable */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
results.passed = results.summary.high === 0
|
|
75
|
+
return results
|
|
76
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect local file/link dependencies that break Railway/CI deploys.
|
|
3
|
+
*
|
|
4
|
+
* Blocks push when package.json contains:
|
|
5
|
+
* - "file:../..." or "file:../../..." local path references
|
|
6
|
+
* - "link:..." npm link references
|
|
7
|
+
* - workspace:* protocol references outside the monorepo
|
|
8
|
+
*
|
|
9
|
+
* Also ensures @soulbatical/* packages use the latest published versions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { glob } from 'glob'
|
|
13
|
+
import { readFileSync } from 'fs'
|
|
14
|
+
|
|
15
|
+
export const meta = {
|
|
16
|
+
id: 'local-deps',
|
|
17
|
+
name: 'Local Dependency Detection',
|
|
18
|
+
category: 'stability',
|
|
19
|
+
severity: 'critical',
|
|
20
|
+
description: 'Blocks deploy-breaking file: and link: dependencies in package.json'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TETRA_PACKAGES = {
|
|
24
|
+
'@soulbatical/tetra-core': '0.3.5',
|
|
25
|
+
'@soulbatical/tetra-ui': '0.7.2',
|
|
26
|
+
'@soulbatical/tetra-dev-toolkit': '1.20.20',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function run(config, projectRoot) {
|
|
30
|
+
const results = {
|
|
31
|
+
passed: true,
|
|
32
|
+
findings: [],
|
|
33
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const files = await glob('**/package.json', {
|
|
37
|
+
cwd: projectRoot,
|
|
38
|
+
ignore: ['**/node_modules/**', '**/dist/**', '**/.next/**', ...config.ignore]
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
try {
|
|
43
|
+
const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
|
|
44
|
+
const pkg = JSON.parse(content)
|
|
45
|
+
const allDeps = {
|
|
46
|
+
...(pkg.dependencies || {}),
|
|
47
|
+
...(pkg.devDependencies || {})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
51
|
+
const ver = String(version)
|
|
52
|
+
|
|
53
|
+
// Block file: and link: references
|
|
54
|
+
if (ver.startsWith('file:') || ver.startsWith('link:')) {
|
|
55
|
+
results.findings.push({
|
|
56
|
+
severity: 'critical',
|
|
57
|
+
file,
|
|
58
|
+
line: 0,
|
|
59
|
+
message: `"${name}": "${ver}" — local dependency will break Railway/CI deploy. Use npm version instead.`,
|
|
60
|
+
snippet: `${name}: ${ver}`
|
|
61
|
+
})
|
|
62
|
+
results.summary.critical++
|
|
63
|
+
results.summary.total++
|
|
64
|
+
results.passed = false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check @soulbatical/* packages are on latest
|
|
68
|
+
if (TETRA_PACKAGES[name]) {
|
|
69
|
+
const minVersion = TETRA_PACKAGES[name]
|
|
70
|
+
// Extract version number from range (^0.3.5 -> 0.3.5)
|
|
71
|
+
const versionNum = ver.replace(/^[\^~>=<]*/, '')
|
|
72
|
+
if (ver.startsWith('file:') || ver.startsWith('link:')) {
|
|
73
|
+
// Already caught above
|
|
74
|
+
} else if (versionNum && versionNum < minVersion && !ver.includes('||')) {
|
|
75
|
+
results.findings.push({
|
|
76
|
+
severity: 'medium',
|
|
77
|
+
file,
|
|
78
|
+
line: 0,
|
|
79
|
+
message: `"${name}": "${ver}" — outdated. Latest: ${minVersion}. Run: npm install ${name}@latest`,
|
|
80
|
+
snippet: `${name}: ${ver} (latest: ^${minVersion})`
|
|
81
|
+
})
|
|
82
|
+
results.summary.medium++
|
|
83
|
+
results.summary.total++
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch { /* skip unparseable */ }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return results
|
|
91
|
+
}
|