@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.
Files changed (50) hide show
  1. package/dist/builtinRules.d.ts +3 -0
  2. package/dist/builtinRules.d.ts.map +1 -0
  3. package/dist/builtinRules.js +41 -0
  4. package/dist/builtinRules.js.map +1 -0
  5. package/dist/frameworks.d.ts +5 -0
  6. package/dist/frameworks.d.ts.map +1 -0
  7. package/dist/frameworks.js +169 -0
  8. package/dist/frameworks.js.map +1 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +22 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/reporters/html.d.ts +3 -0
  14. package/dist/reporters/html.d.ts.map +1 -0
  15. package/dist/reporters/html.js +64 -0
  16. package/dist/reporters/html.js.map +1 -0
  17. package/dist/reporters/sarif.d.ts +52 -0
  18. package/dist/reporters/sarif.d.ts.map +1 -0
  19. package/dist/reporters/sarif.js +76 -0
  20. package/dist/reporters/sarif.js.map +1 -0
  21. package/dist/scan.d.ts +5 -0
  22. package/dist/scan.d.ts.map +1 -0
  23. package/dist/scan.js +315 -0
  24. package/dist/scan.js.map +1 -0
  25. package/dist/types.d.ts +77 -0
  26. package/dist/types.d.ts.map +1 -0
  27. package/dist/types.js +3 -0
  28. package/dist/types.js.map +1 -0
  29. package/package.json +18 -0
  30. package/src/builtinRules.ts +39 -0
  31. package/src/frameworks.ts +202 -0
  32. package/src/index.ts +5 -0
  33. package/src/picomatch.d.ts +10 -0
  34. package/src/reporters/html.ts +65 -0
  35. package/src/reporters/sarif.ts +115 -0
  36. package/src/scan.ts +379 -0
  37. package/src/types.ts +90 -0
  38. package/test/fixtures/monorepo/apps/api/next.config.js +3 -0
  39. package/test/fixtures/monorepo/apps/api/package.json +7 -0
  40. package/test/fixtures/monorepo/apps/kit/package.json +7 -0
  41. package/test/fixtures/monorepo/apps/kit/svelte.config.js +9 -0
  42. package/test/fixtures/monorepo/apps/web/next-env.d.ts +1 -0
  43. package/test/fixtures/monorepo/apps/web/next.config.js +3 -0
  44. package/test/fixtures/monorepo/apps/web/package.json +7 -0
  45. package/test/fixtures/sample-repo/.env +2 -0
  46. package/test/fixtures/sample-repo/keys.txt +3 -0
  47. package/test/fixtures/sample-repo/src/index.ts +1 -0
  48. package/test/frameworksWorkspace.test.js +15 -0
  49. package/test/scanProject.test.js +15 -0
  50. package/tsconfig.json +8 -0
package/src/scan.ts ADDED
@@ -0,0 +1,379 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+
5
+ import fg from 'fast-glob'
6
+ import picomatch from 'picomatch'
7
+ import { z } from 'zod'
8
+ import YAML from 'yaml'
9
+
10
+ import { BUILTIN_RULES } from './builtinRules'
11
+ import { detectFrameworks } from './frameworks'
12
+ import type {
13
+ Finding,
14
+ FindingLocation,
15
+ Rule,
16
+ ScanOptions,
17
+ ScanResult,
18
+ Severity,
19
+ SeverityName,
20
+ VibeSecConfig,
21
+ } from './types'
22
+
23
+ const DEFAULT_IGNORES = [
24
+ '**/.git/**',
25
+ '**/node_modules/**',
26
+ '**/dist/**',
27
+ '**/build/**',
28
+ '**/coverage/**',
29
+ '**/.next/**',
30
+ '**/.turbo/**',
31
+ '**/.cache/**',
32
+ '**/.yarn/**',
33
+ '**/.pnpm/**',
34
+ ]
35
+
36
+ const DEFAULT_MAX_FILE_SIZE_BYTES = 1024 * 1024
37
+
38
+ export type SeverityNameInput = SeverityName
39
+
40
+ export function severityFromString(name: SeverityNameInput): Severity {
41
+ switch (name) {
42
+ case 'critical':
43
+ return { name, rank: 0 }
44
+ case 'high':
45
+ return { name, rank: 1 }
46
+ case 'medium':
47
+ return { name, rank: 2 }
48
+ case 'low':
49
+ return { name, rank: 3 }
50
+ }
51
+ }
52
+
53
+ function fileExists(p: string): Promise<boolean> {
54
+ return fs
55
+ .stat(p)
56
+ .then(() => true)
57
+ .catch(() => false)
58
+ }
59
+
60
+ function sha256Hex(input: string): string {
61
+ return crypto.createHash('sha256').update(input).digest('hex')
62
+ }
63
+
64
+ function isLikelyBinary(buffer: Buffer): boolean {
65
+ for (const b of buffer) {
66
+ if (b === 0) return true
67
+ }
68
+ return false
69
+ }
70
+
71
+ function computeLineInfo(
72
+ text: string,
73
+ matchIndex: number,
74
+ ): {
75
+ lineNumber: number
76
+ columnNumber: number
77
+ lineText: string
78
+ } {
79
+ const upToMatch = text.slice(0, matchIndex)
80
+ const lines = upToMatch.split('\n')
81
+ const lineNumber = lines.length
82
+ const columnNumber = lines[lines.length - 1]?.length ?? 0
83
+
84
+ const fullLines = text.split('\n')
85
+ const lineText = fullLines[lineNumber - 1] ?? ''
86
+
87
+ return {
88
+ lineNumber,
89
+ columnNumber: columnNumber + 1,
90
+ lineText,
91
+ }
92
+ }
93
+
94
+ function fingerprintForMatch(args: {
95
+ ruleId: string
96
+ relativePath: string
97
+ matchText?: string
98
+ lineText?: string
99
+ }): string {
100
+ const material = [
101
+ `rule:${args.ruleId}`,
102
+ `path:${args.relativePath}`,
103
+ args.matchText ? `match:${args.matchText}` : undefined,
104
+ args.lineText ? `line:${args.lineText.trim()}` : undefined,
105
+ ]
106
+ .filter(Boolean)
107
+ .join('\n')
108
+
109
+ return sha256Hex(material)
110
+ }
111
+
112
+ const ignoreEntrySchema = z.union([
113
+ z.object({
114
+ rule: z.string().min(1),
115
+ reason: z.string().min(1),
116
+ paths: z.array(z.string().min(1)).optional(),
117
+ }),
118
+ z.object({
119
+ finding: z.string().min(1),
120
+ reason: z.string().min(1),
121
+ }),
122
+ ])
123
+
124
+ const configSchema = z.object({
125
+ ignore: z.array(ignoreEntrySchema).optional(),
126
+ })
127
+
128
+ function loadConfig(configRootDir: string, configPath?: string): Promise<VibeSecConfig> {
129
+ const candidates = configPath
130
+ ? [configPath]
131
+ : [path.join(configRootDir, '.vibesec.yaml'), path.join(configRootDir, '.vibesec.yml')]
132
+
133
+ return (async () => {
134
+ for (const candidate of candidates) {
135
+ if (!(await fileExists(candidate))) continue
136
+ const raw = await fs.readFile(candidate, 'utf8')
137
+ const parsed = YAML.parse(raw)
138
+ const validated = configSchema.safeParse(parsed)
139
+ if (!validated.success) {
140
+ throw new Error(`Invalid config at ${candidate}`)
141
+ }
142
+ return validated.data
143
+ }
144
+
145
+ return {}
146
+ })()
147
+ }
148
+
149
+ const ruleSchema: z.ZodType<Rule> = z.object({
150
+ id: z.string().min(1),
151
+ severity: z.union([
152
+ z.literal('critical'),
153
+ z.literal('high'),
154
+ z.literal('medium'),
155
+ z.literal('low'),
156
+ ]),
157
+ title: z.string().min(1),
158
+ description: z.string().optional(),
159
+ matcher: z.union([
160
+ z.object({
161
+ type: z.literal('file_presence'),
162
+ paths: z.array(z.string().min(1)).min(1),
163
+ message: z.string().min(1),
164
+ }),
165
+ z.object({
166
+ type: z.literal('regex'),
167
+ fileGlobs: z.array(z.string().min(1)).min(1),
168
+ pattern: z.string().min(1),
169
+ flags: z.string().optional(),
170
+ message: z.string().min(1),
171
+ }),
172
+ ]),
173
+ })
174
+
175
+ async function loadCustomRules(configRootDir: string, customRulesDir?: string): Promise<Rule[]> {
176
+ const rulesDir = customRulesDir ?? path.join(configRootDir, '.vibesec', 'rules')
177
+ if (!(await fileExists(rulesDir))) return []
178
+
179
+ const entries = await fs.readdir(rulesDir, { withFileTypes: true })
180
+ const ruleFiles = entries
181
+ .filter((e) => e.isFile())
182
+ .map((e) => e.name)
183
+ .filter((name) => name.endsWith('.yml') || name.endsWith('.yaml') || name.endsWith('.json'))
184
+
185
+ const rules: Rule[] = []
186
+
187
+ for (const fileName of ruleFiles) {
188
+ const fullPath = path.join(rulesDir, fileName)
189
+ const raw = await fs.readFile(fullPath, 'utf8')
190
+
191
+ const parsed = fileName.endsWith('.json') ? JSON.parse(raw) : YAML.parse(raw)
192
+ const items = Array.isArray(parsed) ? parsed : [parsed]
193
+
194
+ for (const item of items) {
195
+ const validated = ruleSchema.safeParse(item)
196
+ if (!validated.success) {
197
+ throw new Error(`Invalid custom rule in ${fullPath}`)
198
+ }
199
+ rules.push(validated.data)
200
+ }
201
+ }
202
+
203
+ return rules
204
+ }
205
+
206
+ function isIgnored(config: VibeSecConfig, finding: Finding): boolean {
207
+ const ignores = config.ignore ?? []
208
+
209
+ for (const entry of ignores) {
210
+ if ('finding' in entry) {
211
+ if (entry.finding === finding.fingerprint) return true
212
+ continue
213
+ }
214
+
215
+ if (entry.rule !== finding.ruleId) continue
216
+
217
+ if (!entry.paths || entry.paths.length === 0) return true
218
+ const matchesPath = picomatch(entry.paths, { dot: true })
219
+ if (matchesPath(finding.location.path)) return true
220
+ }
221
+
222
+ return false
223
+ }
224
+
225
+ async function listProjectFiles(rootDir: string): Promise<string[]> {
226
+ return fg('**/*', {
227
+ cwd: rootDir,
228
+ dot: true,
229
+ onlyFiles: true,
230
+ followSymbolicLinks: false,
231
+ ignore: DEFAULT_IGNORES,
232
+ })
233
+ }
234
+
235
+ async function readTextFileIfSafe(fullPath: string, maxBytes: number): Promise<string | null> {
236
+ const stat = await fs.stat(fullPath)
237
+ if (stat.size > maxBytes) return null
238
+
239
+ const handle = await fs.open(fullPath, 'r')
240
+ try {
241
+ const probeSize = Math.min(stat.size, 4096)
242
+ const probe = Buffer.alloc(probeSize)
243
+ await handle.read(probe, 0, probeSize, 0)
244
+ if (isLikelyBinary(probe)) return null
245
+
246
+ return await handle.readFile({ encoding: 'utf8' })
247
+ } finally {
248
+ await handle.close()
249
+ }
250
+ }
251
+
252
+ function makeFinding(args: {
253
+ rule: Rule
254
+ location: FindingLocation
255
+ message: string
256
+ excerpt?: string
257
+ matchText?: string
258
+ lineText?: string
259
+ }): Finding {
260
+ const severity = severityFromString(args.rule.severity)
261
+ const fingerprint = fingerprintForMatch({
262
+ ruleId: args.rule.id,
263
+ relativePath: args.location.path,
264
+ matchText: args.matchText,
265
+ lineText: args.lineText,
266
+ })
267
+
268
+ return {
269
+ ruleId: args.rule.id,
270
+ ruleTitle: args.rule.title,
271
+ ruleDescription: args.rule.description,
272
+ severity: args.rule.severity,
273
+ severityRank: severity.rank,
274
+ message: args.message,
275
+ location: args.location,
276
+ fingerprint,
277
+ excerpt: args.excerpt,
278
+ }
279
+ }
280
+
281
+ export async function scanProject(options: ScanOptions): Promise<ScanResult> {
282
+ const scanDir = path.resolve(options.rootDir)
283
+ const configRootDir = path.resolve(options.configRootDir ?? scanDir)
284
+ const pathBaseDir = path.resolve(options.pathBaseDir ?? scanDir)
285
+ const maxFileSizeBytes = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES
286
+
287
+ const config = await loadConfig(configRootDir, options.configPath)
288
+ const additionalRules = options.additionalRules ?? []
289
+ const rules = [
290
+ ...BUILTIN_RULES,
291
+ ...(await loadCustomRules(configRootDir, options.customRulesDir)),
292
+ ...additionalRules,
293
+ ]
294
+
295
+ const frameworks = options.frameworks ?? (await detectFrameworks(scanDir))
296
+ const files = await listProjectFiles(scanDir)
297
+
298
+ const toBasePath = (scanRelativePath: string): string => {
299
+ const absolutePath = path.join(scanDir, scanRelativePath)
300
+ const rel = path.relative(pathBaseDir, absolutePath)
301
+ return (rel || scanRelativePath).split(path.sep).join('/')
302
+ }
303
+
304
+ const findings: Finding[] = []
305
+ let ignoredFindings = 0
306
+
307
+ for (const rule of rules) {
308
+ if (rule.matcher.type === 'file_presence') {
309
+ const matches = files.filter(picomatch(rule.matcher.paths, { dot: true }))
310
+ for (const relativePath of matches) {
311
+ const finding = makeFinding({
312
+ rule,
313
+ location: { path: toBasePath(relativePath), startLine: 1, startColumn: 1 },
314
+ message: rule.matcher.message,
315
+ })
316
+
317
+ if (isIgnored(config, finding)) {
318
+ ignoredFindings += 1
319
+ continue
320
+ }
321
+
322
+ findings.push(finding)
323
+ }
324
+ continue
325
+ }
326
+
327
+ const compiled = new RegExp(rule.matcher.pattern, rule.matcher.flags)
328
+ const matchesFile = picomatch(rule.matcher.fileGlobs, { dot: true })
329
+
330
+ for (const relativePath of files) {
331
+ if (!matchesFile(relativePath)) continue
332
+
333
+ const fullPath = path.join(scanDir, relativePath)
334
+ let text: string | null
335
+ try {
336
+ text = await readTextFileIfSafe(fullPath, maxFileSizeBytes)
337
+ } catch {
338
+ continue
339
+ }
340
+ if (!text) continue
341
+
342
+ const match = compiled.exec(text)
343
+ if (!match || match.index == null) continue
344
+
345
+ const { lineNumber, columnNumber, lineText } = computeLineInfo(text, match.index)
346
+ const excerpt = lineText.trim().slice(0, 300)
347
+
348
+ const finding = makeFinding({
349
+ rule,
350
+ location: {
351
+ path: toBasePath(relativePath),
352
+ startLine: lineNumber,
353
+ startColumn: columnNumber,
354
+ },
355
+ message: rule.matcher.message,
356
+ excerpt,
357
+ matchText: match[0],
358
+ lineText,
359
+ })
360
+
361
+ if (isIgnored(config, finding)) {
362
+ ignoredFindings += 1
363
+ continue
364
+ }
365
+
366
+ findings.push(finding)
367
+ }
368
+ }
369
+
370
+ findings.sort((a, b) => a.severityRank - b.severityRank)
371
+
372
+ return {
373
+ rootDir: scanDir,
374
+ frameworks,
375
+ scannedFiles: files.length,
376
+ ignoredFindings,
377
+ findings,
378
+ }
379
+ }
package/src/types.ts ADDED
@@ -0,0 +1,90 @@
1
+ export type SeverityName = 'critical' | 'high' | 'medium' | 'low'
2
+
3
+ export type Severity = {
4
+ name: SeverityName
5
+ rank: number
6
+ }
7
+
8
+ export type FindingLocation = {
9
+ path: string
10
+ startLine: number
11
+ startColumn: number
12
+ }
13
+
14
+ export type Finding = {
15
+ ruleId: string
16
+ ruleTitle: string
17
+ ruleDescription?: string
18
+ severity: SeverityName
19
+ severityRank: number
20
+ message: string
21
+ location: FindingLocation
22
+ fingerprint: string
23
+ excerpt?: string
24
+ }
25
+
26
+ export type FrameworkId = 'nextjs' | 'react-native' | 'expo' | 'express' | 'sveltekit'
27
+
28
+ export type FrameworkDetection = {
29
+ id: FrameworkId
30
+ confidence: 'high' | 'medium' | 'low'
31
+ evidence: string[]
32
+ }
33
+
34
+ export type ScanResult = {
35
+ rootDir: string
36
+ frameworks: FrameworkDetection[]
37
+ scannedFiles: number
38
+ ignoredFindings: number
39
+ findings: Finding[]
40
+ }
41
+
42
+ export type ScanOptions = {
43
+ rootDir: string
44
+ pathBaseDir?: string
45
+ configRootDir?: string
46
+ configPath?: string
47
+ customRulesDir?: string
48
+ frameworks?: FrameworkDetection[]
49
+ additionalRules?: Rule[]
50
+ maxFileSizeBytes?: number
51
+ }
52
+
53
+ export type IgnoreByRule = {
54
+ rule: string
55
+ reason: string
56
+ paths?: string[]
57
+ }
58
+
59
+ export type IgnoreByFinding = {
60
+ finding: string
61
+ reason: string
62
+ }
63
+
64
+ export type VibeSecConfig = {
65
+ ignore?: Array<IgnoreByRule | IgnoreByFinding>
66
+ }
67
+
68
+ export type FilePresenceMatcher = {
69
+ type: 'file_presence'
70
+ paths: string[]
71
+ message: string
72
+ }
73
+
74
+ export type RegexMatcher = {
75
+ type: 'regex'
76
+ fileGlobs: string[]
77
+ pattern: string
78
+ flags?: string
79
+ message: string
80
+ }
81
+
82
+ export type RuleMatcher = FilePresenceMatcher | RegexMatcher
83
+
84
+ export type Rule = {
85
+ id: string
86
+ severity: SeverityName
87
+ title: string
88
+ description?: string
89
+ matcher: RuleMatcher
90
+ }
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ productionBrowserSourceMaps: true,
3
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "api",
3
+ "private": true,
4
+ "dependencies": {
5
+ "express": "4.18.2"
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "kit",
3
+ "private": true,
4
+ "dependencies": {
5
+ "@sveltejs/kit": "2.0.0"
6
+ }
7
+ }
@@ -0,0 +1,9 @@
1
+ const config = {
2
+ kit: {
3
+ csrf: {
4
+ checkOrigin: false,
5
+ },
6
+ },
7
+ }
8
+
9
+ module.exports = config
@@ -0,0 +1 @@
1
+ export {}
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ productionBrowserSourceMaps: true,
3
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "web",
3
+ "private": true,
4
+ "dependencies": {
5
+ "next": "14.0.0"
6
+ }
7
+ }
@@ -0,0 +1,2 @@
1
+ # Fixture env file (safe placeholder)
2
+ EXAMPLE_VALUE=not-a-secret
@@ -0,0 +1,3 @@
1
+ -----BEGIN RSA PRIVATE KEY-----
2
+ FAKE
3
+ -----END RSA PRIVATE KEY-----
@@ -0,0 +1 @@
1
+ export const example = 'AKIA1234567890ABCDEF'
@@ -0,0 +1,15 @@
1
+ const assert = require('node:assert/strict')
2
+ const path = require('node:path')
3
+ const test = require('node:test')
4
+
5
+ const { detectFrameworksInWorkspace } = require('../dist/index.js')
6
+
7
+ test('detectFrameworksInWorkspace finds nested frameworks', async () => {
8
+ const fixtureRoot = path.join(__dirname, 'fixtures', 'monorepo')
9
+ const frameworks = await detectFrameworksInWorkspace(fixtureRoot)
10
+
11
+ const ids = frameworks.map((f) => f.id)
12
+ assert.ok(ids.includes('nextjs'))
13
+ assert.ok(ids.includes('express'))
14
+ assert.ok(ids.includes('sveltekit'))
15
+ })
@@ -0,0 +1,15 @@
1
+ const assert = require('node:assert/strict')
2
+ const path = require('node:path')
3
+ const test = require('node:test')
4
+
5
+ const { scanProject } = require('../dist/index.js')
6
+
7
+ test('scanProject finds builtin rule matches', async () => {
8
+ const fixtureRoot = path.join(__dirname, 'fixtures', 'sample-repo')
9
+ const result = await scanProject({ rootDir: fixtureRoot })
10
+
11
+ const ruleIds = result.findings.map((f) => f.ruleId)
12
+ assert.ok(ruleIds.includes('core/env-file-committed'))
13
+ assert.ok(ruleIds.includes('core/private-key-committed'))
14
+ assert.ok(ruleIds.includes('core/hardcoded-aws-access-key-id'))
15
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }