reposec 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.
@@ -0,0 +1,509 @@
1
+ import type {
2
+ CategoryBreakdown,
3
+ CheckResult,
4
+ FileGroup,
5
+ Finding,
6
+ FindingCategory,
7
+ ScanReport,
8
+ Severity,
9
+ } from "./types";
10
+ import { BAND_LABELS, groupBySeverity, SEVERITY_ORDER } from "./scoring";
11
+
12
+ const HEADING = (text: string) => `${text}\n${"-".repeat(text.length)}`;
13
+
14
+ function severityLabel(s: Severity): string {
15
+ return s.charAt(0).toUpperCase() + s.slice(1);
16
+ }
17
+
18
+ function checkStatus(s: CheckResult["status"]): string {
19
+ switch (s) {
20
+ case "pass":
21
+ return "PASS";
22
+ case "fail":
23
+ return "FAIL";
24
+ case "warn":
25
+ return "WARN";
26
+ case "missing":
27
+ return "MISSING";
28
+ case "info":
29
+ return "INFO";
30
+ case "skip":
31
+ return "SKIP";
32
+ }
33
+ }
34
+
35
+ function findingLine(f: Finding): string {
36
+ const loc = f.file
37
+ ? f.line
38
+ ? `\`${f.file}:${f.line}\``
39
+ : `\`${f.file}\``
40
+ : "_general_";
41
+ const confidence = f.confidence
42
+ ? `, ${f.verified ? "verified" : `${f.confidence} confidence`}`
43
+ : "";
44
+ return `- **${f.title}** (${severityLabel(f.severity)}${confidence}) \u2014 ${loc}\n - _Why:_ ${f.description}\n - _Fix:_ ${f.fix}`;
45
+ }
46
+
47
+ function categoryLabel(c: FindingCategory): string {
48
+ switch (c) {
49
+ case "environment":
50
+ return "Environment & secrets";
51
+ case "documentation":
52
+ return "Documentation";
53
+ case "package":
54
+ return "Package & scripts";
55
+ case "github":
56
+ return "GitHub features";
57
+ case "secret":
58
+ return "Secret patterns";
59
+ case "docker":
60
+ return "Container hygiene";
61
+ case "community":
62
+ return "Community health";
63
+ case "ci":
64
+ return "CI quality";
65
+ case "metadata":
66
+ return "Repository metadata";
67
+ case "code":
68
+ return "Source code patterns";
69
+ case "dependencies":
70
+ return "Dependency hygiene";
71
+ }
72
+ }
73
+
74
+ function formatDate(iso: string): string {
75
+ try {
76
+ return new Date(iso).toUTCString();
77
+ } catch {
78
+ return iso;
79
+ }
80
+ }
81
+
82
+ function formatDuration(ms: number): string {
83
+ if (ms < 1000) return `${ms} ms`;
84
+ return `${(ms / 1000).toFixed(2)} s`;
85
+ }
86
+
87
+ function repoMetadataBlock(report: ScanReport): string {
88
+ const r = report.repo;
89
+ const lines: string[] = [];
90
+ lines.push(`- **Repository:** [${r.owner}/${r.repo}](${r.htmlUrl})`);
91
+ lines.push(`- **Description:** ${r.description?.trim() || "_(none)_"}`);
92
+ if (r.language) lines.push(`- **Primary language:** ${r.language}`);
93
+ if (r.licenseSpdxId)
94
+ lines.push(`- **License:** ${r.licenseSpdxId}${r.licenseName ? ` (${r.licenseName})` : ""}`);
95
+ lines.push(`- **Default branch:** \`${r.defaultBranch}\``);
96
+ lines.push(
97
+ `- **Stars / forks / open issues:** ${(r.stars ?? 0).toLocaleString()} / ${(r.forks ?? 0).toLocaleString()} / ${(r.openIssues ?? 0).toLocaleString()}`,
98
+ );
99
+ if (typeof r.sizeKb === "number")
100
+ lines.push(`- **Repository size:** ${(r.sizeKb / 1024).toFixed(2)} MB`);
101
+ if (r.pushedAt) lines.push(`- **Last push:** ${formatDate(r.pushedAt)}`);
102
+ if (r.archived) lines.push(`- **Archived:** yes`);
103
+ if (r.isTemplate) lines.push(`- **Template repository:** yes`);
104
+ if (r.topics && r.topics.length > 0)
105
+ lines.push(`- **Topics:** ${r.topics.map((t) => `\`${t}\``).join(", ")}`);
106
+ lines.push(`- **Scanned at:** ${formatDate(report.scannedAt)}`);
107
+ lines.push(`- **Duration:** ${formatDuration(report.durationMs)}`);
108
+ return lines.join("\n");
109
+ }
110
+
111
+ function categoryBreakdownBlock(by: Record<FindingCategory, CategoryBreakdown>): string {
112
+ return Object.entries(by)
113
+ .filter(([, v]) => v.total > 0)
114
+ .map(([k, v]) => {
115
+ const ratio = v.total === 0 ? 0 : Math.round((v.passed / v.total) * 100);
116
+ return `- **${categoryLabel(k as FindingCategory)}:** ${v.passed} / ${v.total} passed (${ratio}%)`;
117
+ })
118
+ .join("\n");
119
+ }
120
+
121
+ function checksTableBlock(checks: CheckResult[]): string {
122
+ const rows = checks
123
+ .map(
124
+ (c) =>
125
+ `| ${checkStatus(c.status)} | ${categoryLabel(c.category)} | ${c.title} | ${c.file ? `\`${c.file}\`` : "_\u2014_"} | ${c.message.replace(/\|/g, "\\|").slice(0, 200)} |`,
126
+ )
127
+ .join("\n");
128
+ return [
129
+ `| Status | Category | Check | File | Detail |`,
130
+ `| ------ | -------- | ----- | ---- | ------ |`,
131
+ rows,
132
+ ].join("\n");
133
+ }
134
+
135
+ function findingsByFileBlock(groups: FileGroup[]): string {
136
+ if (groups.length === 0) return "_No file-level findings._";
137
+ return groups
138
+ .map((g) => {
139
+ const counts = SEVERITY_ORDER.filter((s) => g.counts[s] > 0)
140
+ .map((s) => `${g.counts[s]} ${severityLabel(s).toLowerCase()}`)
141
+ .join(", ");
142
+ return [
143
+ `### \`${g.path}\` (${g.findings.length} finding${g.findings.length === 1 ? "" : "s"} \u2014 ${counts})`,
144
+ ``,
145
+ ...g.findings.map(
146
+ (f) =>
147
+ `- **${f.title}** (${severityLabel(f.severity)})${f.line ? ` \u2014 line ${f.line}` : ""}\n - _Fix:_ ${f.fix}`,
148
+ ),
149
+ ``,
150
+ ].join("\n");
151
+ })
152
+ .join("\n");
153
+ }
154
+
155
+ function findingsByCategoryBlock(findings: Finding[]): string {
156
+ const grouped = new Map<FindingCategory, Finding[]>();
157
+ for (const f of findings) {
158
+ if (!grouped.has(f.category)) grouped.set(f.category, []);
159
+ grouped.get(f.category)!.push(f);
160
+ }
161
+ if (grouped.size === 0) return "_No findings._";
162
+ return Array.from(grouped.entries())
163
+ .map(([cat, items]) => {
164
+ return [
165
+ `### ${categoryLabel(cat)} (${items.length})`,
166
+ ``,
167
+ ...items.map(findingLine),
168
+ ``,
169
+ ].join("\n");
170
+ })
171
+ .join("\n");
172
+ }
173
+
174
+ export function generateMarkdownReport(report: ScanReport): string {
175
+ const grouped = groupBySeverity(report.findings);
176
+ const counts = SEVERITY_ORDER.map(
177
+ (s) => `- ${severityLabel(s)}: ${grouped[s].length}`,
178
+ ).join("\n");
179
+
180
+ const findingsBlock = SEVERITY_ORDER.flatMap((s) => grouped[s])
181
+ .map(findingLine)
182
+ .join("\n");
183
+
184
+ const missing = report.summary.filesMissing.length
185
+ ? report.summary.filesMissing.map((f) => `- \`${f}\``).join("\n")
186
+ : "_None \u2014 all expected files are present._";
187
+
188
+ return [
189
+ `# RepoSec Security Report`,
190
+ ``,
191
+ repoMetadataBlock(report),
192
+ ``,
193
+ HEADING("Security Score"),
194
+ ``,
195
+ `**Score:** ${report.score} / 100 \u2014 ${BAND_LABELS[report.scoreBand]}`,
196
+ ``,
197
+ HEADING("Scan summary"),
198
+ ``,
199
+ `- **Checks performed:** ${report.summary.totalChecks}`,
200
+ `- **Checks passed:** ${report.summary.passed}`,
201
+ `- **Checks failed:** ${report.summary.failed}`,
202
+ `- **Total findings:** ${report.summary.totalFindings}`,
203
+ `- **Files checked:** ${report.summary.filesChecked}`,
204
+ `- **Missing files:** ${report.summary.filesMissing.length}`,
205
+ ``,
206
+ HEADING("Findings by severity"),
207
+ ``,
208
+ counts,
209
+ ``,
210
+ HEADING("Checks by category"),
211
+ ``,
212
+ categoryBreakdownBlock(report.summary.byCategory),
213
+ ``,
214
+ HEADING("Checks performed"),
215
+ ``,
216
+ checksTableBlock(report.summary.checks),
217
+ ``,
218
+ HEADING("Missing files"),
219
+ ``,
220
+ missing,
221
+ ``,
222
+ HEADING("Findings by file"),
223
+ ``,
224
+ findingsByFileBlock(report.fileGroups),
225
+ ``,
226
+ HEADING("All findings (grouped by category)"),
227
+ ``,
228
+ findingsByCategoryBlock(report.findings) ||
229
+ "_No findings \u2014 the repository looks clean on the checks we ran._",
230
+ ``,
231
+ HEADING("All findings (flat list)"),
232
+ ``,
233
+ findingsBlock || "_No findings._",
234
+ ``,
235
+ HEADING("Files inspected"),
236
+ ``,
237
+ report.filesChecked.map((f) => `- \`${f}\``).join("\n"),
238
+ ``,
239
+ HEADING("Disclaimer"),
240
+ ``,
241
+ `_RepoSec is a static, rule-based scanner. Findings are hints, not guarantees.${" "}Heuristics can produce false positives; review every finding before changing production code._`,
242
+ ].join("\n");
243
+ }
244
+
245
+ export function generateJsonReport(report: ScanReport): string {
246
+ return JSON.stringify(report, null, 2);
247
+ }
248
+
249
+ function sarifLevel(severity: Severity): "error" | "warning" | "note" {
250
+ if (severity === "critical" || severity === "high") return "error";
251
+ if (severity === "medium" || severity === "low") return "warning";
252
+ return "note";
253
+ }
254
+
255
+ function sarifRuleId(finding: Finding): string {
256
+ const base = finding.id.split("-").slice(0, 3).join("-");
257
+ return base || finding.category;
258
+ }
259
+
260
+ export function generateSarifReport(report: ScanReport): string {
261
+ const rules = new Map<string, Finding>();
262
+ for (const finding of report.findings) {
263
+ const id = sarifRuleId(finding);
264
+ if (!rules.has(id)) rules.set(id, finding);
265
+ }
266
+
267
+ return JSON.stringify(
268
+ {
269
+ version: "2.1.0",
270
+ $schema:
271
+ "https://json.schemastore.org/sarif-2.1.0.json",
272
+ runs: [
273
+ {
274
+ tool: {
275
+ driver: {
276
+ name: "RepoSec",
277
+ informationUri: "https://github.com/zanesense/reposec",
278
+ rules: Array.from(rules.entries()).map(([id, finding]) => ({
279
+ id,
280
+ name: finding.title,
281
+ shortDescription: { text: finding.title },
282
+ fullDescription: { text: finding.description },
283
+ help: { text: finding.fix },
284
+ properties: {
285
+ category: finding.category,
286
+ severity: finding.severity,
287
+ confidence: finding.confidence ?? "medium",
288
+ },
289
+ })),
290
+ },
291
+ },
292
+ results: report.findings.map((finding) => ({
293
+ ruleId: sarifRuleId(finding),
294
+ level: sarifLevel(finding.severity),
295
+ message: {
296
+ text: `${finding.title}: ${finding.description}`,
297
+ },
298
+ locations: finding.file
299
+ ? [
300
+ {
301
+ physicalLocation: {
302
+ artifactLocation: { uri: finding.file },
303
+ region: finding.line
304
+ ? { startLine: finding.line }
305
+ : undefined,
306
+ },
307
+ },
308
+ ]
309
+ : [],
310
+ fingerprints: finding.fingerprint
311
+ ? { secretFingerprint: finding.fingerprint }
312
+ : undefined,
313
+ properties: {
314
+ category: finding.category,
315
+ severity: finding.severity,
316
+ confidence: finding.confidence ?? "medium",
317
+ verified: finding.verified ?? false,
318
+ evidence: finding.evidence,
319
+ remediation: finding.fix,
320
+ },
321
+ })),
322
+ },
323
+ ],
324
+ },
325
+ null,
326
+ 2,
327
+ );
328
+ }
329
+
330
+ export function generateSecurityMdTemplate(): string {
331
+ return `# Security Policy
332
+
333
+ ## Supported Versions
334
+
335
+ | Version | Supported |
336
+ | ------- | ------------------ |
337
+ | latest | :white_check_mark: |
338
+ | older | :x: |
339
+
340
+ ## Reporting a Vulnerability
341
+
342
+ If you discover a security issue, please email **security@example.com** instead of
343
+ opening a public issue. Include:
344
+
345
+ - A clear description of the issue
346
+ - Steps to reproduce
347
+ - The impact you believe it has
348
+ - Any suggested fix (optional)
349
+
350
+ We aim to acknowledge new reports within **3 business days** and ship a fix or
351
+ mitigation within **30 days** for high-severity issues.
352
+
353
+ ## Scope
354
+
355
+ This policy applies to the code in this repository and any official packages we
356
+ publish. It does not cover social engineering, physical attacks, or denial of
357
+ service.
358
+ `;
359
+ }
360
+
361
+ export function generateEnvExampleTemplate(): string {
362
+ return `# Copy this file to .env and fill in the values for your environment.
363
+ # Never commit a real .env file. Keep secrets out of version control.
364
+
365
+ # --- Core ---
366
+ NODE_ENV=development
367
+ PORT=3000
368
+
369
+ # --- Database ---
370
+ # DATABASE_URL=postgres://user:password@localhost:5432/app
371
+
372
+ # --- Auth ---
373
+ # JWT_SECRET=replace-with-a-long-random-string
374
+ # SESSION_SECRET=replace-with-a-long-random-string
375
+
376
+ # --- External APIs ---
377
+ # OPENAI_API_KEY=
378
+ # STRIPE_SECRET_KEY=
379
+ # GITHUB_TOKEN=
380
+ `;
381
+ }
382
+
383
+ export function generateIssueChecklist(report: ScanReport): string {
384
+ const grouped = groupBySeverity(report.findings);
385
+ const items = SEVERITY_ORDER.flatMap((s) => grouped[s]);
386
+ const checklist = items
387
+ .map(
388
+ (f) =>
389
+ `- [ ] **${severityLabel(f.severity)}** \u2014 ${f.title}${
390
+ f.file ? ` _(${f.file}${f.line ? `:${f.line}` : ""})_` : ""
391
+ }`,
392
+ )
393
+ .join("\n");
394
+
395
+ return [
396
+ `## RepoSec follow-up`,
397
+ ``,
398
+ `Repository: ${report.repo.owner}/${report.repo.repo}`,
399
+ `Score: **${report.score} / 100** (${BAND_LABELS[report.scoreBand]})`,
400
+ ``,
401
+ `### Checklist`,
402
+ ``,
403
+ checklist || `- [ ] No findings \u2014 the repository looks clean on the checks we ran.`,
404
+ ``,
405
+ `### Suggested order`,
406
+ ``,
407
+ `1. Rotate or remove any exposed secrets.`,
408
+ `2. Add the missing hygiene files (SECURITY.md, .env.example, .gitignore).`,
409
+ `3. Wire up Dependabot and a basic CI workflow.`,
410
+ `4. Document setup and environment variables in the README.`,
411
+ ].join("\n");
412
+ }
413
+
414
+ export function generateFixPrompt(report: ScanReport): string {
415
+ const grouped = groupBySeverity(report.findings);
416
+ const top = SEVERITY_ORDER.flatMap((s) => grouped[s]).slice(0, 12);
417
+
418
+ const items = top
419
+ .map(
420
+ (f, i) =>
421
+ `${i + 1}. **${f.title}** (${severityLabel(f.severity)})${
422
+ f.file ? ` \u2014 \`${f.file}${f.line ? `:${f.line}` : ""}\`` : ""
423
+ }\n Fix: ${f.fix}`,
424
+ )
425
+ .join("\n");
426
+
427
+ return `You are a defensive security assistant. Review this RepoSec report and fix the high-risk items first.
428
+
429
+ Repository: ${report.repo.owner}/${report.repo.repo}
430
+ Score: ${report.score} / 100 (${BAND_LABELS[report.scoreBand]})
431
+
432
+ Top issues:
433
+ ${items || "- No findings \u2014 the repository looks clean."}
434
+
435
+ Rules:
436
+ - Do not rewrite unrelated files.
437
+ - Do not expose secrets, tokens, or credentials in source.
438
+ - Use placeholders for any environment variable value (e.g. KEY=value).
439
+ - Add .env.example with placeholder values only.
440
+ - Update .gitignore to cover .env, build output, and dependencies.
441
+ - Add SECURITY.md with responsible-disclosure instructions.
442
+ - Improve README setup and environment variable sections.
443
+ - Explain every change after editing.
444
+
445
+ When you are done, summarize what you changed and what still needs a human review.`;
446
+ }
447
+
448
+ export interface DownloadPayload {
449
+ filename: string;
450
+ mime: string;
451
+ content: string;
452
+ }
453
+
454
+ export function buildDownload(
455
+ report: ScanReport,
456
+ kind:
457
+ | "report"
458
+ | "security-md"
459
+ | "env-example"
460
+ | "issue"
461
+ | "fix-prompt"
462
+ | "json"
463
+ | "sarif",
464
+ ): DownloadPayload {
465
+ switch (kind) {
466
+ case "report":
467
+ return {
468
+ filename: `reposec-${report.repo.owner}-${report.repo.repo}.md`,
469
+ mime: "text/markdown",
470
+ content: generateMarkdownReport(report),
471
+ };
472
+ case "security-md":
473
+ return {
474
+ filename: "SECURITY.md",
475
+ mime: "text/markdown",
476
+ content: generateSecurityMdTemplate(),
477
+ };
478
+ case "env-example":
479
+ return {
480
+ filename: ".env.example",
481
+ mime: "text/plain",
482
+ content: generateEnvExampleTemplate(),
483
+ };
484
+ case "issue":
485
+ return {
486
+ filename: `reposec-issues-${report.repo.owner}-${report.repo.repo}.md`,
487
+ mime: "text/markdown",
488
+ content: generateIssueChecklist(report),
489
+ };
490
+ case "fix-prompt":
491
+ return {
492
+ filename: `reposec-fix-prompt.md`,
493
+ mime: "text/markdown",
494
+ content: generateFixPrompt(report),
495
+ };
496
+ case "json":
497
+ return {
498
+ filename: `reposec-${report.repo.owner}-${report.repo.repo}.json`,
499
+ mime: "application/json",
500
+ content: generateJsonReport(report),
501
+ };
502
+ case "sarif":
503
+ return {
504
+ filename: `reposec-${report.repo.owner}-${report.repo.repo}.sarif`,
505
+ mime: "application/sarif+json",
506
+ content: generateSarifReport(report),
507
+ };
508
+ }
509
+ }
@@ -0,0 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function fingerprintSecret(value: string): string {
4
+ return createHash("sha256").update(value.trim()).digest("hex").slice(0, 16);
5
+ }