@reliabilityworks/core 0.1.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/dist/builtinRules.d.ts +3 -0
- package/dist/builtinRules.d.ts.map +1 -0
- package/dist/builtinRules.js +41 -0
- package/dist/builtinRules.js.map +1 -0
- package/dist/frameworks.d.ts +5 -0
- package/dist/frameworks.d.ts.map +1 -0
- package/dist/frameworks.js +169 -0
- package/dist/frameworks.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/reporters/html.d.ts +3 -0
- package/dist/reporters/html.d.ts.map +1 -0
- package/dist/reporters/html.js +64 -0
- package/dist/reporters/html.js.map +1 -0
- package/dist/reporters/sarif.d.ts +52 -0
- package/dist/reporters/sarif.d.ts.map +1 -0
- package/dist/reporters/sarif.js +76 -0
- package/dist/reporters/sarif.js.map +1 -0
- package/dist/scan.d.ts +5 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +315 -0
- package/dist/scan.js.map +1 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +18 -0
- package/src/builtinRules.ts +39 -0
- package/src/frameworks.ts +202 -0
- package/src/index.ts +5 -0
- package/src/picomatch.d.ts +10 -0
- package/src/reporters/html.ts +65 -0
- package/src/reporters/sarif.ts +115 -0
- package/src/scan.ts +379 -0
- package/src/types.ts +90 -0
- package/test/fixtures/monorepo/apps/api/next.config.js +3 -0
- package/test/fixtures/monorepo/apps/api/package.json +7 -0
- package/test/fixtures/monorepo/apps/kit/package.json +7 -0
- package/test/fixtures/monorepo/apps/kit/svelte.config.js +9 -0
- package/test/fixtures/monorepo/apps/web/next-env.d.ts +1 -0
- package/test/fixtures/monorepo/apps/web/next.config.js +3 -0
- package/test/fixtures/monorepo/apps/web/package.json +7 -0
- package/test/fixtures/sample-repo/.env +2 -0
- package/test/fixtures/sample-repo/keys.txt +3 -0
- package/test/fixtures/sample-repo/src/index.ts +1 -0
- package/test/frameworksWorkspace.test.js +15 -0
- package/test/scanProject.test.js +15 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
2
|
+
import type { Stats } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import fg from 'fast-glob'
|
|
6
|
+
|
|
7
|
+
import type { FrameworkDetection, FrameworkId } from './types'
|
|
8
|
+
|
|
9
|
+
type PackageJson = {
|
|
10
|
+
dependencies?: Record<string, string>
|
|
11
|
+
devDependencies?: Record<string, string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const WORKSPACE_IGNORES = [
|
|
15
|
+
'**/.git/**',
|
|
16
|
+
'**/node_modules/**',
|
|
17
|
+
'**/dist/**',
|
|
18
|
+
'**/build/**',
|
|
19
|
+
'**/coverage/**',
|
|
20
|
+
'**/.next/**',
|
|
21
|
+
'**/.turbo/**',
|
|
22
|
+
'**/.cache/**',
|
|
23
|
+
'**/.yarn/**',
|
|
24
|
+
'**/.pnpm/**',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
async function pathStat(p: string): Promise<Stats | null> {
|
|
28
|
+
try {
|
|
29
|
+
return await stat(p)
|
|
30
|
+
} catch {
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function hasFile(rootDir: string, relativePath: string): Promise<boolean> {
|
|
36
|
+
const fileStat = await pathStat(path.join(rootDir, relativePath))
|
|
37
|
+
return fileStat?.isFile() ?? false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function hasDir(rootDir: string, relativePath: string): Promise<boolean> {
|
|
41
|
+
const dirStat = await pathStat(path.join(rootDir, relativePath))
|
|
42
|
+
return dirStat?.isDirectory() ?? false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readPackageJson(rootDir: string): Promise<PackageJson | null> {
|
|
46
|
+
const packageJsonPath = path.join(rootDir, 'package.json')
|
|
47
|
+
const fileStat = await pathStat(packageJsonPath)
|
|
48
|
+
if (!fileStat?.isFile()) return null
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const raw = await readFile(packageJsonPath, 'utf8')
|
|
52
|
+
return JSON.parse(raw) as PackageJson
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function packageHasDep(pkg: PackageJson | null, name: string): boolean {
|
|
59
|
+
if (!pkg) return false
|
|
60
|
+
return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name])
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pushIf(value: string, condition: boolean, into: string[]) {
|
|
64
|
+
if (condition) into.push(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function confidenceFromEvidenceCount(count: number): FrameworkDetection['confidence'] {
|
|
68
|
+
if (count >= 3) return 'high'
|
|
69
|
+
if (count >= 2) return 'medium'
|
|
70
|
+
return 'low'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeDetection(id: FrameworkId, evidence: string[]): FrameworkDetection {
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
confidence: confidenceFromEvidenceCount(evidence.length),
|
|
77
|
+
evidence,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sortFrameworks(frameworks: FrameworkDetection[]): void {
|
|
82
|
+
frameworks.sort((a, b) => {
|
|
83
|
+
const score = (d: FrameworkDetection) =>
|
|
84
|
+
d.confidence === 'high' ? 3 : d.confidence === 'medium' ? 2 : 1
|
|
85
|
+
return score(b) - score(a)
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function detectFrameworks(rootDir: string): Promise<FrameworkDetection[]> {
|
|
90
|
+
const pkg = await readPackageJson(rootDir)
|
|
91
|
+
|
|
92
|
+
const hasNextDep = packageHasDep(pkg, 'next')
|
|
93
|
+
const hasNextEnv = await hasFile(rootDir, 'next-env.d.ts')
|
|
94
|
+
|
|
95
|
+
const nextEvidence: string[] = []
|
|
96
|
+
pushIf('dependency: next', hasNextDep, nextEvidence)
|
|
97
|
+
pushIf('file: next-env.d.ts', hasNextEnv, nextEvidence)
|
|
98
|
+
pushIf('dir: app/', await hasDir(rootDir, 'app'), nextEvidence)
|
|
99
|
+
pushIf('dir: pages/', await hasDir(rootDir, 'pages'), nextEvidence)
|
|
100
|
+
|
|
101
|
+
const nextConfigFiles = ['next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts']
|
|
102
|
+
for (const f of nextConfigFiles) {
|
|
103
|
+
pushIf(`file: ${f}`, await hasFile(rootDir, f), nextEvidence)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const hasReactNativeDep = packageHasDep(pkg, 'react-native')
|
|
107
|
+
|
|
108
|
+
const rnEvidence: string[] = []
|
|
109
|
+
pushIf('dependency: react-native', hasReactNativeDep, rnEvidence)
|
|
110
|
+
pushIf('dir: ios/', await hasDir(rootDir, 'ios'), rnEvidence)
|
|
111
|
+
pushIf('dir: android/', await hasDir(rootDir, 'android'), rnEvidence)
|
|
112
|
+
pushIf('file: metro.config.js', await hasFile(rootDir, 'metro.config.js'), rnEvidence)
|
|
113
|
+
|
|
114
|
+
const expoEvidence: string[] = []
|
|
115
|
+
pushIf('dependency: expo', packageHasDep(pkg, 'expo'), expoEvidence)
|
|
116
|
+
pushIf('file: app.json', await hasFile(rootDir, 'app.json'), expoEvidence)
|
|
117
|
+
pushIf('file: app.config.js', await hasFile(rootDir, 'app.config.js'), expoEvidence)
|
|
118
|
+
pushIf('file: app.config.ts', await hasFile(rootDir, 'app.config.ts'), expoEvidence)
|
|
119
|
+
pushIf('file: eas.json', await hasFile(rootDir, 'eas.json'), expoEvidence)
|
|
120
|
+
|
|
121
|
+
const expressEvidence: string[] = []
|
|
122
|
+
pushIf('dependency: express', packageHasDep(pkg, 'express'), expressEvidence)
|
|
123
|
+
|
|
124
|
+
const hasSvelteKitDep = packageHasDep(pkg, '@sveltejs/kit')
|
|
125
|
+
|
|
126
|
+
const kitEvidence: string[] = []
|
|
127
|
+
pushIf('dependency: @sveltejs/kit', hasSvelteKitDep, kitEvidence)
|
|
128
|
+
pushIf('file: svelte.config.js', await hasFile(rootDir, 'svelte.config.js'), kitEvidence)
|
|
129
|
+
pushIf('file: svelte.config.ts', await hasFile(rootDir, 'svelte.config.ts'), kitEvidence)
|
|
130
|
+
pushIf('dir: src/routes/', await hasDir(rootDir, path.join('src', 'routes')), kitEvidence)
|
|
131
|
+
|
|
132
|
+
const frameworks: FrameworkDetection[] = []
|
|
133
|
+
|
|
134
|
+
if (hasNextDep || hasNextEnv) frameworks.push(makeDetection('nextjs', nextEvidence))
|
|
135
|
+
|
|
136
|
+
if (hasReactNativeDep) {
|
|
137
|
+
const combined = Array.from(new Set([...rnEvidence, ...expoEvidence]))
|
|
138
|
+
frameworks.push(makeDetection('react-native', combined))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (expoEvidence.length > 0) frameworks.push(makeDetection('expo', expoEvidence))
|
|
142
|
+
|
|
143
|
+
if (expressEvidence.length > 0) frameworks.push(makeDetection('express', expressEvidence))
|
|
144
|
+
|
|
145
|
+
if (hasSvelteKitDep) frameworks.push(makeDetection('sveltekit', kitEvidence))
|
|
146
|
+
|
|
147
|
+
sortFrameworks(frameworks)
|
|
148
|
+
|
|
149
|
+
return frameworks
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function listWorkspaceProjectRoots(rootDir: string): Promise<string[]> {
|
|
153
|
+
const packageJsonPaths = await fg('**/package.json', {
|
|
154
|
+
cwd: rootDir,
|
|
155
|
+
dot: true,
|
|
156
|
+
onlyFiles: true,
|
|
157
|
+
followSymbolicLinks: false,
|
|
158
|
+
ignore: WORKSPACE_IGNORES,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const resolvedRoot = path.resolve(rootDir)
|
|
162
|
+
|
|
163
|
+
const roots = Array.from(
|
|
164
|
+
new Set(packageJsonPaths.map((relativePath) => path.join(rootDir, path.dirname(relativePath)))),
|
|
165
|
+
)
|
|
166
|
+
.map((p) => path.resolve(p))
|
|
167
|
+
.filter((p) => p !== resolvedRoot)
|
|
168
|
+
|
|
169
|
+
roots.sort()
|
|
170
|
+
|
|
171
|
+
return roots
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function detectFrameworksInWorkspace(rootDir: string): Promise<FrameworkDetection[]> {
|
|
175
|
+
const roots = await listWorkspaceProjectRoots(rootDir)
|
|
176
|
+
|
|
177
|
+
const byFramework = new Map<FrameworkId, Set<string>>()
|
|
178
|
+
|
|
179
|
+
for (const projectRoot of roots) {
|
|
180
|
+
const detections = await detectFrameworks(projectRoot)
|
|
181
|
+
if (detections.length === 0) continue
|
|
182
|
+
|
|
183
|
+
const relativeRoot = path.relative(rootDir, projectRoot) || '.'
|
|
184
|
+
for (const detection of detections) {
|
|
185
|
+
const existing = byFramework.get(detection.id) ?? new Set<string>()
|
|
186
|
+
for (const evidence of detection.evidence) {
|
|
187
|
+
existing.add(`${relativeRoot}: ${evidence}`)
|
|
188
|
+
}
|
|
189
|
+
byFramework.set(detection.id, existing)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const frameworks: FrameworkDetection[] = []
|
|
194
|
+
|
|
195
|
+
for (const [id, evidenceSet] of byFramework.entries()) {
|
|
196
|
+
frameworks.push(makeDetection(id, Array.from(evidenceSet)))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
sortFrameworks(frameworks)
|
|
200
|
+
|
|
201
|
+
return frameworks
|
|
202
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ScanResult } from '../types'
|
|
2
|
+
|
|
3
|
+
function escapeHtml(input: string): string {
|
|
4
|
+
return input
|
|
5
|
+
.replaceAll('&', '&')
|
|
6
|
+
.replaceAll('<', '<')
|
|
7
|
+
.replaceAll('>', '>')
|
|
8
|
+
.replaceAll('"', '"')
|
|
9
|
+
.replaceAll("'", ''')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function toHtml(result: ScanResult): string {
|
|
13
|
+
const frameworks = result.frameworks.map((f) => escapeHtml(f.id)).join(', ')
|
|
14
|
+
const findings = result.findings
|
|
15
|
+
.map((f) => {
|
|
16
|
+
const location = `${escapeHtml(f.location.path)}:${f.location.startLine}`
|
|
17
|
+
const title = escapeHtml(f.ruleTitle)
|
|
18
|
+
const message = escapeHtml(f.message)
|
|
19
|
+
const severity = escapeHtml(f.severity.toUpperCase())
|
|
20
|
+
|
|
21
|
+
return `
|
|
22
|
+
<div class="finding">
|
|
23
|
+
<div class="finding__header">
|
|
24
|
+
<span class="badge badge--${f.severity}">${severity}</span>
|
|
25
|
+
<span class="finding__rule">${escapeHtml(f.ruleId)}</span>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="finding__title">${title}</div>
|
|
28
|
+
<div class="finding__location">${location}</div>
|
|
29
|
+
<div class="finding__message">${message}</div>
|
|
30
|
+
</div>
|
|
31
|
+
`
|
|
32
|
+
})
|
|
33
|
+
.join('\n')
|
|
34
|
+
|
|
35
|
+
return `<!doctype html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="utf-8" />
|
|
39
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
40
|
+
<title>VibeSec report</title>
|
|
41
|
+
<style>
|
|
42
|
+
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; margin: 24px; color: #e5e7eb; background: #0b1220; }
|
|
43
|
+
h1 { margin: 0 0 8px 0; }
|
|
44
|
+
.meta { color: #9ca3af; margin-bottom: 16px; }
|
|
45
|
+
.finding { border: 1px solid #1f2937; border-radius: 10px; padding: 12px; margin: 12px 0; background: #0f172a; }
|
|
46
|
+
.finding__header { display: flex; gap: 10px; align-items: center; margin-bottom: 8px; }
|
|
47
|
+
.finding__rule { color: #cbd5e1; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono"; font-size: 12px; }
|
|
48
|
+
.finding__title { font-weight: 600; margin-bottom: 6px; }
|
|
49
|
+
.finding__location { color: #9ca3af; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono"; font-size: 12px; margin-bottom: 6px; }
|
|
50
|
+
.finding__message { color: #e5e7eb; }
|
|
51
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .02em; }
|
|
52
|
+
.badge--critical { background: #7f1d1d; color: #fecaca; }
|
|
53
|
+
.badge--high { background: #9a3412; color: #ffedd5; }
|
|
54
|
+
.badge--medium { background: #92400e; color: #fef3c7; }
|
|
55
|
+
.badge--low { background: #1f2937; color: #e5e7eb; }
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<h1>VibeSec report</h1>
|
|
60
|
+
<div class="meta">Frameworks: ${frameworks || 'none'} · Findings: ${result.findings.length}</div>
|
|
61
|
+
${findings || '<div class="meta">No findings.</div>'}
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
64
|
+
`
|
|
65
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Finding, ScanResult, SeverityName } from '../types'
|
|
2
|
+
|
|
3
|
+
type SarifLevel = 'error' | 'warning' | 'note'
|
|
4
|
+
|
|
5
|
+
type SarifReport = {
|
|
6
|
+
version: '2.1.0'
|
|
7
|
+
$schema: string
|
|
8
|
+
runs: Array<{
|
|
9
|
+
tool: {
|
|
10
|
+
driver: {
|
|
11
|
+
name: string
|
|
12
|
+
version: string
|
|
13
|
+
informationUri?: string
|
|
14
|
+
rules?: Array<{
|
|
15
|
+
id: string
|
|
16
|
+
name?: string
|
|
17
|
+
shortDescription: { text: string }
|
|
18
|
+
fullDescription?: { text: string }
|
|
19
|
+
help?: { text: string }
|
|
20
|
+
properties?: Record<string, unknown>
|
|
21
|
+
}>
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
results: Array<{
|
|
25
|
+
ruleId: string
|
|
26
|
+
level: SarifLevel
|
|
27
|
+
message: { text: string }
|
|
28
|
+
locations: Array<{
|
|
29
|
+
physicalLocation: {
|
|
30
|
+
artifactLocation: { uri: string }
|
|
31
|
+
region: { startLine: number; startColumn: number }
|
|
32
|
+
}
|
|
33
|
+
}>
|
|
34
|
+
partialFingerprints?: Record<string, string>
|
|
35
|
+
properties?: Record<string, unknown>
|
|
36
|
+
}>
|
|
37
|
+
}>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sarifLevel(severity: SeverityName): SarifLevel {
|
|
41
|
+
switch (severity) {
|
|
42
|
+
case 'critical':
|
|
43
|
+
case 'high':
|
|
44
|
+
return 'error'
|
|
45
|
+
case 'medium':
|
|
46
|
+
return 'warning'
|
|
47
|
+
case 'low':
|
|
48
|
+
return 'note'
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ruleKey(finding: Finding): string {
|
|
53
|
+
return finding.ruleId
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function toSarif(result: ScanResult): SarifReport {
|
|
57
|
+
const rulesById = new Map<string, Finding>()
|
|
58
|
+
for (const finding of result.findings) {
|
|
59
|
+
const id = ruleKey(finding)
|
|
60
|
+
if (!rulesById.has(id)) rulesById.set(id, finding)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const rules = Array.from(rulesById.values()).map((finding) => ({
|
|
64
|
+
id: finding.ruleId,
|
|
65
|
+
name: finding.ruleId,
|
|
66
|
+
shortDescription: { text: finding.ruleTitle },
|
|
67
|
+
fullDescription: finding.ruleDescription ? { text: finding.ruleDescription } : undefined,
|
|
68
|
+
help: { text: finding.message },
|
|
69
|
+
properties: {
|
|
70
|
+
severity: finding.severity,
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
const results = result.findings.map((finding) => ({
|
|
75
|
+
ruleId: finding.ruleId,
|
|
76
|
+
level: sarifLevel(finding.severity),
|
|
77
|
+
message: { text: finding.message },
|
|
78
|
+
locations: [
|
|
79
|
+
{
|
|
80
|
+
physicalLocation: {
|
|
81
|
+
artifactLocation: { uri: finding.location.path },
|
|
82
|
+
region: {
|
|
83
|
+
startLine: finding.location.startLine,
|
|
84
|
+
startColumn: finding.location.startColumn,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
partialFingerprints: {
|
|
90
|
+
'vibesec/fingerprint': finding.fingerprint,
|
|
91
|
+
},
|
|
92
|
+
properties: {
|
|
93
|
+
severity: finding.severity,
|
|
94
|
+
fingerprint: finding.fingerprint,
|
|
95
|
+
},
|
|
96
|
+
}))
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
version: '2.1.0',
|
|
100
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
101
|
+
runs: [
|
|
102
|
+
{
|
|
103
|
+
tool: {
|
|
104
|
+
driver: {
|
|
105
|
+
name: 'vibesec',
|
|
106
|
+
version: '0.0.0',
|
|
107
|
+
informationUri: 'https://github.com/Reliability-Works/vibesec',
|
|
108
|
+
rules,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
results,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
}
|