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.
package/lib/scoring.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { Finding, ScoreBand, Severity } from "./types";
2
+ import { SEVERITY_WEIGHT } from "./rules";
3
+
4
+ export const SCORE_START = 100;
5
+
6
+ export function calculateScore(findings: Finding[]): number {
7
+ const total = findings.reduce(
8
+ (acc, f) => acc + (SEVERITY_WEIGHT[f.severity] ?? 0),
9
+ 0,
10
+ );
11
+ return Math.max(0, Math.min(SCORE_START, SCORE_START - total));
12
+ }
13
+
14
+ export function scoreBand(score: number): ScoreBand {
15
+ if (score >= 90) return "excellent";
16
+ if (score >= 75) return "good";
17
+ if (score >= 55) return "fair";
18
+ if (score >= 30) return "weak";
19
+ return "critical";
20
+ }
21
+
22
+ export const BAND_LABELS: Record<ScoreBand, string> = {
23
+ excellent: "Excellent",
24
+ good: "Good",
25
+ fair: "Fair",
26
+ weak: "Weak",
27
+ critical: "Critical",
28
+ };
29
+
30
+ export const BAND_DESCRIPTIONS: Record<ScoreBand, string> = {
31
+ excellent: "Repository hygiene is strong. Keep the rules running on every PR.",
32
+ good: "Most hygiene rules pass. Address the high and medium items to reach excellent.",
33
+ fair: "Several gaps to close. Tackle the high-severity findings first.",
34
+ weak: "Many hygiene gaps detected. Treat the critical and high items as urgent.",
35
+ critical: "Hygiene is failing on several fronts. Stop new work and fix the high-risk items first.",
36
+ };
37
+
38
+ export const SEVERITY_ORDER: Severity[] = [
39
+ "critical",
40
+ "high",
41
+ "medium",
42
+ "low",
43
+ "info",
44
+ ];
45
+
46
+ export function groupBySeverity(
47
+ findings: Finding[],
48
+ ): Record<Severity, Finding[]> {
49
+ const grouped: Record<Severity, Finding[]> = {
50
+ critical: [],
51
+ high: [],
52
+ medium: [],
53
+ low: [],
54
+ info: [],
55
+ };
56
+ for (const f of findings) grouped[f.severity].push(f);
57
+ return grouped;
58
+ }
package/lib/types.ts ADDED
@@ -0,0 +1,133 @@
1
+ export type Severity = "critical" | "high" | "medium" | "low" | "info";
2
+ export type FindingConfidence = "verified" | "high" | "medium" | "low";
3
+
4
+ export type FindingCategory =
5
+ | "environment"
6
+ | "documentation"
7
+ | "package"
8
+ | "github"
9
+ | "secret"
10
+ | "docker"
11
+ | "community"
12
+ | "ci"
13
+ | "metadata"
14
+ | "code"
15
+ | "dependencies";
16
+
17
+ export interface Finding {
18
+ id: string;
19
+ title: string;
20
+ description: string;
21
+ severity: Severity;
22
+ category: FindingCategory;
23
+ confidence?: FindingConfidence;
24
+ fingerprint?: string;
25
+ verified?: boolean;
26
+ file?: string;
27
+ line?: number;
28
+ evidence?: string;
29
+ fix: string;
30
+ fixPrompt?: string;
31
+ }
32
+
33
+ export interface RepoFile {
34
+ path: string;
35
+ content: string;
36
+ }
37
+
38
+ export interface RepoMetadata {
39
+ owner: string;
40
+ repo: string;
41
+ defaultBranch: string;
42
+ description?: string | null;
43
+ stars?: number;
44
+ forks?: number;
45
+ openIssues?: number;
46
+ isPrivate: boolean;
47
+ htmlUrl: string;
48
+ homepageUrl?: string | null;
49
+ topics?: string[];
50
+ archived?: boolean;
51
+ isTemplate?: boolean;
52
+ language?: string | null;
53
+ sizeKb?: number;
54
+ pushedAt?: string;
55
+ updatedAt?: string;
56
+ createdAt?: string;
57
+ licenseSpdxId?: string | null;
58
+ licenseName?: string | null;
59
+ }
60
+
61
+ export interface RepoData {
62
+ metadata: RepoMetadata;
63
+ files: RepoFile[];
64
+ fileTree: string[];
65
+ workflows: string[];
66
+ hasDependabot: boolean;
67
+ hasWorkflows: boolean;
68
+ hasIssueTemplate: boolean;
69
+ hasCodeowners: boolean;
70
+ hasPullRequestTemplate: boolean;
71
+ hasDockerfile: boolean;
72
+ hasDockerignore: boolean;
73
+ hasChangelog: boolean;
74
+ hasContributing: boolean;
75
+ hasCodeOfConduct: boolean;
76
+ primaryLockfile: string | null;
77
+ extraLockfiles: string[];
78
+ }
79
+
80
+ export type CheckStatus = "pass" | "fail" | "warn" | "missing" | "info" | "skip";
81
+
82
+ export interface CheckResult {
83
+ id: string;
84
+ category: FindingCategory;
85
+ title: string;
86
+ status: CheckStatus;
87
+ message: string;
88
+ file?: string;
89
+ line?: number;
90
+ }
91
+
92
+ export interface CategoryBreakdown {
93
+ total: number;
94
+ passed: number;
95
+ failed: number;
96
+ }
97
+
98
+ export interface ScanSummary {
99
+ totalChecks: number;
100
+ passed: number;
101
+ failed: number;
102
+ totalFindings: number;
103
+ filesChecked: number;
104
+ filesMissing: string[];
105
+ byCategory: Record<FindingCategory, CategoryBreakdown>;
106
+ checks: CheckResult[];
107
+ }
108
+
109
+ export interface FileGroup {
110
+ path: string;
111
+ findings: Finding[];
112
+ counts: Record<Severity, number>;
113
+ }
114
+
115
+ export interface ScanReport {
116
+ repo: RepoMetadata;
117
+ score: number;
118
+ scoreBand: ScoreBand;
119
+ summary: ScanSummary;
120
+ findings: Finding[];
121
+ filesChecked: string[];
122
+ fileGroups: FileGroup[];
123
+ scannedAt: string;
124
+ durationMs: number;
125
+ }
126
+
127
+ export type ScoreBand = "excellent" | "good" | "fair" | "weak" | "critical";
128
+
129
+ export interface ParsedRepoUrl {
130
+ owner: string;
131
+ repo: string;
132
+ url: string;
133
+ }
package/lib/utils.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ export function formatNumber(value: number): string {
9
+ return new Intl.NumberFormat("en-US").format(value);
10
+ }
11
+
12
+ export function truncate(value: string, max = 80): string {
13
+ if (value.length <= max) return value;
14
+ return value.slice(0, max - 1) + "\u2026";
15
+ }
16
+
17
+ export function maskSecret(value: string): string {
18
+ if (!value) return "";
19
+ const trimmed = value.trim();
20
+ if (trimmed.length <= 8) return "****";
21
+ const start = trimmed.slice(0, 4);
22
+ const end = trimmed.slice(-4);
23
+ return `${start}${"*".repeat(Math.max(4, trimmed.length - 8))}${end}`;
24
+ }
@@ -0,0 +1,67 @@
1
+ import type { Finding } from "./types";
2
+ import type { SecretCandidate } from "./scanner";
3
+
4
+ const VERIFY_TIMEOUT_MS = 6_000;
5
+
6
+ async function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
7
+ const controller = new AbortController();
8
+ const timeout = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
9
+ try {
10
+ return await fetch(url, { ...init, signal: controller.signal });
11
+ } finally {
12
+ clearTimeout(timeout);
13
+ }
14
+ }
15
+
16
+ async function verifyCandidate(candidate: SecretCandidate): Promise<boolean | null> {
17
+ const name = candidate.patternName.toLowerCase();
18
+ const value = candidate.value.trim();
19
+
20
+ try {
21
+ if (name.includes("github token")) {
22
+ const res = await fetchWithTimeout("https://api.github.com/user", {
23
+ headers: {
24
+ Authorization: `Bearer ${value}`,
25
+ "User-Agent": "RepoSec-Secret-Verifier",
26
+ },
27
+ });
28
+ return res.status === 200;
29
+ }
30
+
31
+ if (name.includes("stripe")) {
32
+ const res = await fetchWithTimeout("https://api.stripe.com/v1/account", {
33
+ headers: { Authorization: `Bearer ${value}` },
34
+ });
35
+ return res.status === 200;
36
+ }
37
+
38
+ if (name.includes("huggingface")) {
39
+ const res = await fetchWithTimeout("https://huggingface.co/api/whoami-v2", {
40
+ headers: { Authorization: `Bearer ${value}` },
41
+ });
42
+ return res.status === 200;
43
+ }
44
+ } catch {
45
+ return null;
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ export async function verifyFindings(
52
+ findings: Finding[],
53
+ candidates: SecretCandidate[] | undefined,
54
+ ): Promise<void> {
55
+ if (!candidates || candidates.length === 0) return;
56
+ const byId = new Map(findings.map((finding) => [finding.id, finding]));
57
+
58
+ for (const candidate of candidates.slice(0, 25)) {
59
+ const finding = byId.get(candidate.findingId);
60
+ if (!finding) continue;
61
+ const verified = await verifyCandidate(candidate);
62
+ if (verified !== true) continue;
63
+ finding.verified = true;
64
+ finding.confidence = "verified";
65
+ finding.description = `${finding.description} The token was verified live by RepoSec's opt-in verifier.`;
66
+ }
67
+ }
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "reposec",
3
+ "version": "0.1.0",
4
+ "description": "Read-only security hygiene scanner for public GitHub repositories and local source trees.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zanesense/reposec.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/zanesense/reposec/issues"
12
+ },
13
+ "homepage": "https://reposec.zanesense.dev",
14
+ "bin": {
15
+ "reposec": "bin/reposec.mjs"
16
+ },
17
+ "files": [
18
+ "bin/",
19
+ "lib/",
20
+ "scripts/reposec-cli.mts",
21
+ "README.md",
22
+ "LICENSE",
23
+ "SECURITY.md"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "scripts": {
29
+ "dev": "next dev",
30
+ "build": "next build",
31
+ "start": "next start",
32
+ "lint": "eslint",
33
+ "typecheck": "tsc --noEmit",
34
+ "clean": "node -e \"const fs=require('fs');for (const p of ['.next','tsconfig.tsbuildinfo']) { try { fs.rmSync(p,{recursive:true,force:true}); } catch {} } console.log('cleaned')\"",
35
+ "screenshots": "node scripts/capture-screenshots.mjs",
36
+ "test:scanner": "tsx scripts/test-scanner.mts && tsx scripts/test-client-bundle.mts",
37
+ "scan:local": "tsx scripts/reposec-cli.mts"
38
+ },
39
+ "dependencies": {
40
+ "@radix-ui/react-slot": "^1.2.4",
41
+ "class-variance-authority": "^0.7.1",
42
+ "clsx": "^2.1.1",
43
+ "lucide-react": "^0.490.0",
44
+ "next": "16.2.7",
45
+ "react": "19.2.4",
46
+ "react-dom": "19.2.4",
47
+ "sonner": "^2.0.7",
48
+ "tailwind-merge": "^3.6.0",
49
+ "tw-animate-css": "^1.4.0",
50
+ "tsx": "^4.22.4"
51
+ },
52
+ "devDependencies": {
53
+ "@tailwindcss/postcss": "^4",
54
+ "@types/node": "^20",
55
+ "@types/react": "^19",
56
+ "@types/react-dom": "^19",
57
+ "eslint": "^9",
58
+ "eslint-config-next": "16.2.7",
59
+ "playwright": "^1.60.0",
60
+ "tailwindcss": "^4",
61
+ "typescript": "^5"
62
+ },
63
+ "overrides": {
64
+ "csstype": "3.2.2"
65
+ }
66
+ }
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ import { writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { loadLocalRepo } from "../lib/local-repo.ts";
5
+ import { runScan } from "../lib/scanner.ts";
6
+ import { calculateScore, scoreBand } from "../lib/scoring.ts";
7
+ import {
8
+ generateJsonReport,
9
+ generateMarkdownReport,
10
+ generateSarifReport,
11
+ } from "../lib/exporters.ts";
12
+ import type { ScanReport } from "../lib/types.ts";
13
+ import { verifyFindings } from "../lib/verification.ts";
14
+
15
+ type ColorName = "red" | "yellow" | "blue" | "cyan" | "green" | "gray" | "bold";
16
+
17
+ const ANSI: Record<ColorName | "reset", string> = {
18
+ reset: "\x1b[0m",
19
+ bold: "\x1b[1m",
20
+ red: "\x1b[31m",
21
+ yellow: "\x1b[33m",
22
+ blue: "\x1b[34m",
23
+ cyan: "\x1b[36m",
24
+ green: "\x1b[32m",
25
+ gray: "\x1b[90m",
26
+ };
27
+
28
+ function color(text: string, name: ColorName, enabled: boolean): string {
29
+ return enabled ? `${ANSI[name]}${text}${ANSI.reset}` : text;
30
+ }
31
+
32
+ function scoreColor(score: number): ColorName {
33
+ if (score >= 85) return "green";
34
+ if (score >= 70) return "cyan";
35
+ if (score >= 50) return "yellow";
36
+ return "red";
37
+ }
38
+
39
+ function severityColor(severity: string): ColorName {
40
+ switch (severity.toLowerCase()) {
41
+ case "critical":
42
+ case "high":
43
+ return "red";
44
+ case "medium":
45
+ return "yellow";
46
+ case "low":
47
+ return "blue";
48
+ default:
49
+ return "gray";
50
+ }
51
+ }
52
+
53
+ function colorizeMarkdownReport(output: string, report: ScanReport, enabled: boolean): string {
54
+ if (!enabled) return output;
55
+
56
+ const lines = output.split("\n");
57
+ return lines
58
+ .map((line, index) => {
59
+ if (line.startsWith("# ")) return color(line, "bold", true);
60
+ if (/^-{3,}$/.test(line)) return color(line, "gray", true);
61
+ if (
62
+ /^[A-Za-z].*$/.test(line) &&
63
+ /^-+$/.test(lines[index + 1] ?? "") &&
64
+ !line.startsWith("-") &&
65
+ !line.includes(":")
66
+ ) {
67
+ return color(line, "cyan", true);
68
+ }
69
+ if (line.startsWith("**Score:**")) {
70
+ return line.replace(
71
+ `${report.score} / 100`,
72
+ color(`${report.score} / 100`, scoreColor(report.score), true),
73
+ );
74
+ }
75
+ return line
76
+ .replace(
77
+ /^- (Critical|High|Medium|Low|Info):/i,
78
+ (match, severity: string) => `- ${color(`${severity}:`, severityColor(severity), true)}`,
79
+ )
80
+ .replace(
81
+ /\((Critical|High|Medium|Low|Info)([,)]?)/g,
82
+ (_match, severity: string, suffix: string) =>
83
+ `(${color(severity, severityColor(severity), true)}${suffix}`,
84
+ )
85
+ .replace(/\b(pass|passed)\b/gi, (match) => color(match, "green", true))
86
+ .replace(/\b(fail|failed)\b/gi, (match) => color(match, "red", true))
87
+ .replace(/\b(warn|warning)\b/gi, (match) => color(match, "yellow", true))
88
+ .replace(/\b(info)\b/gi, (match) => color(match, "blue", true));
89
+ })
90
+ .join("\n");
91
+ }
92
+
93
+ function usage(useColor = shouldUseColor("auto", "markdown", null)): never {
94
+ console.log(`${color("RepoSec CLI", "bold", useColor)}
95
+
96
+ Usage:
97
+ reposec [path] [--history] [--format markdown|json|sarif] [--out file] [--verify]
98
+
99
+ Options:
100
+ --history Include recent git history blobs in the scan.
101
+ --format <kind> Output format. Defaults to markdown.
102
+ --out <file> Write output to a file instead of stdout.
103
+ --verify Opt in to safe provider verification where supported.
104
+ --color Force colored terminal output for markdown reports.
105
+ --no-color Disable colored terminal output.
106
+ `);
107
+ process.exit(1);
108
+ }
109
+
110
+ function shouldUseColor(
111
+ colorMode: "auto" | "always" | "never",
112
+ format: "markdown" | "json" | "sarif",
113
+ outFile: string | null,
114
+ ): boolean {
115
+ if (format !== "markdown" || outFile) return false;
116
+ if (colorMode === "always") return true;
117
+ if (colorMode === "never" || process.env.NO_COLOR) return false;
118
+ return Boolean(process.stdout.isTTY || process.env.FORCE_COLOR);
119
+ }
120
+
121
+ const args = process.argv.slice(2);
122
+ let root = ".";
123
+ let includeHistory = false;
124
+ let format: "markdown" | "json" | "sarif" = "markdown";
125
+ let outFile: string | null = null;
126
+ let verify = false;
127
+ let colorMode: "auto" | "always" | "never" = "auto";
128
+
129
+ for (let i = 0; i < args.length; i++) {
130
+ const arg = args[i];
131
+ if (arg === "--help" || arg === "-h") usage(shouldUseColor(colorMode, "markdown", null));
132
+ if (arg === "--history") {
133
+ includeHistory = true;
134
+ continue;
135
+ }
136
+ if (arg === "--verify") {
137
+ verify = true;
138
+ continue;
139
+ }
140
+ if (arg === "--color") {
141
+ colorMode = "always";
142
+ continue;
143
+ }
144
+ if (arg === "--no-color") {
145
+ colorMode = "never";
146
+ continue;
147
+ }
148
+ if (arg === "--format") {
149
+ const next = args[++i];
150
+ if (next !== "markdown" && next !== "json" && next !== "sarif") {
151
+ usage(shouldUseColor(colorMode, "markdown", null));
152
+ }
153
+ format = next;
154
+ continue;
155
+ }
156
+ if (arg === "--out") {
157
+ outFile = args[++i] ?? null;
158
+ if (!outFile) usage(shouldUseColor(colorMode, format, outFile));
159
+ continue;
160
+ }
161
+ if (arg.startsWith("--")) usage(shouldUseColor(colorMode, format, outFile));
162
+ root = arg;
163
+ }
164
+
165
+ const started = Date.now();
166
+ const repo = await loadLocalRepo(root, { includeHistory });
167
+ const result = runScan(repo, { collectSecretCandidates: verify });
168
+ if (verify) {
169
+ await verifyFindings(result.findings, result.secretCandidates);
170
+ }
171
+ const score = calculateScore(result.findings);
172
+ const report: ScanReport = {
173
+ repo: repo.metadata,
174
+ score,
175
+ scoreBand: scoreBand(score),
176
+ summary: result.summary,
177
+ findings: result.findings,
178
+ filesChecked: result.filesChecked,
179
+ fileGroups: result.fileGroups,
180
+ scannedAt: new Date().toISOString(),
181
+ durationMs: Date.now() - started,
182
+ };
183
+
184
+ const output =
185
+ format === "json"
186
+ ? generateJsonReport(report)
187
+ : format === "sarif"
188
+ ? generateSarifReport(report)
189
+ : generateMarkdownReport(report);
190
+
191
+ if (outFile) {
192
+ await writeFile(path.resolve(outFile), output, "utf8");
193
+ } else {
194
+ console.log(colorizeMarkdownReport(output, report, shouldUseColor(colorMode, format, outFile)));
195
+ }