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/LICENSE +21 -0
- package/README.md +733 -0
- package/SECURITY.md +29 -0
- package/bin/reposec.mjs +20 -0
- package/lib/baseline.ts +100 -0
- package/lib/client-bundle.ts +202 -0
- package/lib/exporters.ts +509 -0
- package/lib/fingerprint.ts +5 -0
- package/lib/github.ts +365 -0
- package/lib/local-repo.ts +182 -0
- package/lib/rules.ts +662 -0
- package/lib/scan-targets.ts +155 -0
- package/lib/scanner.ts +2015 -0
- package/lib/scoring.ts +58 -0
- package/lib/types.ts +133 -0
- package/lib/utils.ts +24 -0
- package/lib/verification.ts +67 -0
- package/package.json +66 -0
- package/scripts/reposec-cli.mts +195 -0
package/lib/github.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ParsedRepoUrl,
|
|
3
|
+
RepoData,
|
|
4
|
+
RepoFile,
|
|
5
|
+
RepoMetadata,
|
|
6
|
+
} from "./types";
|
|
7
|
+
import { isLikelySecretScanPath, secretScanPriority } from "./scan-targets";
|
|
8
|
+
|
|
9
|
+
const GITHUB_API = "https://api.github.com";
|
|
10
|
+
const RAW_BASE = "https://raw.githubusercontent.com";
|
|
11
|
+
|
|
12
|
+
const IMPORTANT_PATHS = [
|
|
13
|
+
"README.md",
|
|
14
|
+
"package.json",
|
|
15
|
+
".gitignore",
|
|
16
|
+
".env",
|
|
17
|
+
".env.local",
|
|
18
|
+
".env.production",
|
|
19
|
+
".env.development",
|
|
20
|
+
".env.example",
|
|
21
|
+
".reposecignore",
|
|
22
|
+
"reposec-baseline.json",
|
|
23
|
+
".reposec-baseline.json",
|
|
24
|
+
"SECURITY.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"Dockerfile",
|
|
27
|
+
".dockerignore",
|
|
28
|
+
"docker-compose.yml",
|
|
29
|
+
"docker-compose.yaml",
|
|
30
|
+
"requirements.txt",
|
|
31
|
+
"pyproject.toml",
|
|
32
|
+
"go.mod",
|
|
33
|
+
"Cargo.toml",
|
|
34
|
+
"Gemfile",
|
|
35
|
+
"composer.json",
|
|
36
|
+
".github/CODEOWNERS",
|
|
37
|
+
"CODEOWNERS",
|
|
38
|
+
"CHANGELOG.md",
|
|
39
|
+
"CONTRIBUTING.md",
|
|
40
|
+
"CODE_OF_CONDUCT.md",
|
|
41
|
+
".github/PULL_REQUEST_TEMPLATE.md",
|
|
42
|
+
"PULL_REQUEST_TEMPLATE.md",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const WORKFLOW_DIR = ".github/workflows";
|
|
46
|
+
const DEPENDABOT_PATHS = [
|
|
47
|
+
".github/dependabot.yml",
|
|
48
|
+
".github/dependabot.yaml",
|
|
49
|
+
];
|
|
50
|
+
const ISSUE_TEMPLATE_DIR = ".github/ISSUE_TEMPLATE";
|
|
51
|
+
const LOCKFILES = [
|
|
52
|
+
"package-lock.json",
|
|
53
|
+
"yarn.lock",
|
|
54
|
+
"pnpm-lock.yaml",
|
|
55
|
+
"bun.lockb",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export class GitHubError extends Error {
|
|
59
|
+
status: number;
|
|
60
|
+
code: "not_found" | "private" | "rate_limited" | "invalid" | "unknown";
|
|
61
|
+
constructor(
|
|
62
|
+
message: string,
|
|
63
|
+
status: number,
|
|
64
|
+
code: GitHubError["code"],
|
|
65
|
+
) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "GitHubError";
|
|
68
|
+
this.status = status;
|
|
69
|
+
this.code = code;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseRepoUrl(input: string): ParsedRepoUrl | null {
|
|
74
|
+
if (!input) return null;
|
|
75
|
+
const trimmed = input.trim();
|
|
76
|
+
const cleaned = trimmed
|
|
77
|
+
.replace(/^https?:\/\//i, "")
|
|
78
|
+
.replace(/^github\.com\//i, "")
|
|
79
|
+
.replace(/\.git$/i, "")
|
|
80
|
+
.replace(/\/$/, "");
|
|
81
|
+
|
|
82
|
+
const parts = cleaned.split("/").filter(Boolean);
|
|
83
|
+
if (parts.length < 2) return null;
|
|
84
|
+
|
|
85
|
+
const [owner, repo] = parts;
|
|
86
|
+
if (!/^[\w.-]+$/.test(owner) || !/^[\w.-]+$/.test(repo)) return null;
|
|
87
|
+
if (owner.length > 39 || repo.length > 100) return null;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
owner,
|
|
91
|
+
repo,
|
|
92
|
+
url: `https://github.com/${owner}/${repo}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildHeaders(): HeadersInit {
|
|
97
|
+
const headers: Record<string, string> = {
|
|
98
|
+
Accept: "application/vnd.github+json",
|
|
99
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
100
|
+
"User-Agent": "RepoSec-Scanner",
|
|
101
|
+
};
|
|
102
|
+
const token = process.env.GITHUB_TOKEN;
|
|
103
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
104
|
+
return headers;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function githubFetch<T>(path: string): Promise<T> {
|
|
108
|
+
const res = await fetch(`${GITHUB_API}${path}`, {
|
|
109
|
+
headers: buildHeaders(),
|
|
110
|
+
cache: "no-store",
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
if (res.status === 404) {
|
|
114
|
+
throw new GitHubError(
|
|
115
|
+
"Repository not found. Check the URL or make sure the repo is public.",
|
|
116
|
+
404,
|
|
117
|
+
"not_found",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (res.status === 403 || res.status === 429) {
|
|
121
|
+
const remaining = res.headers.get("x-ratelimit-remaining");
|
|
122
|
+
if (remaining === "0") {
|
|
123
|
+
const reset = res.headers.get("x-ratelimit-reset");
|
|
124
|
+
throw new GitHubError(
|
|
125
|
+
`GitHub rate limit reached. Try again later${
|
|
126
|
+
reset ? ` (resets at ${new Date(Number(reset) * 1000).toUTCString()})` : ""
|
|
127
|
+
}.`,
|
|
128
|
+
res.status,
|
|
129
|
+
"rate_limited",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
throw new GitHubError(
|
|
133
|
+
"Access denied. The repository may be private.",
|
|
134
|
+
res.status,
|
|
135
|
+
"private",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
throw new GitHubError(
|
|
139
|
+
`GitHub request failed with status ${res.status}.`,
|
|
140
|
+
res.status,
|
|
141
|
+
"unknown",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
return (await res.json()) as T;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function fetchRawFile(
|
|
148
|
+
owner: string,
|
|
149
|
+
repo: string,
|
|
150
|
+
ref: string,
|
|
151
|
+
path: string,
|
|
152
|
+
): Promise<string | null> {
|
|
153
|
+
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
|
154
|
+
const url = `${RAW_BASE}/${owner}/${repo}/${ref}/${encodedPath}`;
|
|
155
|
+
try {
|
|
156
|
+
const res = await fetch(url, {
|
|
157
|
+
headers: { "User-Agent": "RepoSec-Scanner" },
|
|
158
|
+
cache: "no-store",
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok) return null;
|
|
161
|
+
if (res.headers.get("content-type")?.includes("application/octet-stream")) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const text = await res.text();
|
|
165
|
+
if (text.length > 2_000_000) return text.slice(0, 2_000_000);
|
|
166
|
+
return text;
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function mapWithConcurrency<T, R>(
|
|
173
|
+
items: T[],
|
|
174
|
+
limit: number,
|
|
175
|
+
mapper: (item: T) => Promise<R>,
|
|
176
|
+
): Promise<R[]> {
|
|
177
|
+
const results = new Array<R>(items.length);
|
|
178
|
+
let next = 0;
|
|
179
|
+
|
|
180
|
+
async function worker(): Promise<void> {
|
|
181
|
+
while (next < items.length) {
|
|
182
|
+
const index = next++;
|
|
183
|
+
results[index] = await mapper(items[index]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const workers = Array.from(
|
|
188
|
+
{ length: Math.min(limit, items.length) },
|
|
189
|
+
() => worker(),
|
|
190
|
+
);
|
|
191
|
+
await Promise.all(workers);
|
|
192
|
+
return results;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface GithubRepoResponse {
|
|
196
|
+
name: string;
|
|
197
|
+
full_name: string;
|
|
198
|
+
description: string | null;
|
|
199
|
+
default_branch: string;
|
|
200
|
+
private: boolean;
|
|
201
|
+
html_url: string;
|
|
202
|
+
homepage?: string | null;
|
|
203
|
+
stargazers_count: number;
|
|
204
|
+
forks_count: number;
|
|
205
|
+
open_issues_count?: number;
|
|
206
|
+
topics?: string[];
|
|
207
|
+
archived?: boolean;
|
|
208
|
+
is_template?: boolean;
|
|
209
|
+
language?: string | null;
|
|
210
|
+
size?: number;
|
|
211
|
+
pushed_at?: string;
|
|
212
|
+
updated_at?: string;
|
|
213
|
+
created_at?: string;
|
|
214
|
+
license?: { spdx_id: string | null; name: string } | null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface TreeEntry {
|
|
218
|
+
path: string;
|
|
219
|
+
mode: string;
|
|
220
|
+
type: "blob" | "tree" | "commit";
|
|
221
|
+
sha: string;
|
|
222
|
+
size?: number;
|
|
223
|
+
url: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface TreeResponse {
|
|
227
|
+
sha: string;
|
|
228
|
+
url: string;
|
|
229
|
+
tree: TreeEntry[];
|
|
230
|
+
truncated: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function fetchRepoData(
|
|
234
|
+
owner: string,
|
|
235
|
+
repo: string,
|
|
236
|
+
): Promise<RepoData> {
|
|
237
|
+
const meta = await githubFetch<GithubRepoResponse>(`/repos/${owner}/${repo}`);
|
|
238
|
+
|
|
239
|
+
if (meta.private) {
|
|
240
|
+
throw new GitHubError(
|
|
241
|
+
"This repository is private. RepoSec only scans public repositories.",
|
|
242
|
+
403,
|
|
243
|
+
"private",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const ref = meta.default_branch || "main";
|
|
248
|
+
|
|
249
|
+
const tree = await githubFetch<TreeResponse>(
|
|
250
|
+
`/repos/${owner}/${repo}/git/trees/${ref}?recursive=1`,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const blobEntries = tree.tree.filter((entry) => entry.type === "blob");
|
|
254
|
+
const fileTree = blobEntries.map((entry) => entry.path);
|
|
255
|
+
|
|
256
|
+
const candidates = new Set<string>(IMPORTANT_PATHS);
|
|
257
|
+
for (const entry of blobEntries) {
|
|
258
|
+
const path = entry.path;
|
|
259
|
+
if (path.startsWith(`${WORKFLOW_DIR}/`)) candidates.add(path);
|
|
260
|
+
if (path.startsWith(`${ISSUE_TEMPLATE_DIR}/`)) candidates.add(path);
|
|
261
|
+
for (const dep of DEPENDABOT_PATHS) {
|
|
262
|
+
if (path === dep) candidates.add(path);
|
|
263
|
+
}
|
|
264
|
+
if (isLikelySecretScanPath(path, entry.size)) candidates.add(path);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const candidatePaths = Array.from(candidates).sort((a, b) => {
|
|
268
|
+
const priorityDelta = secretScanPriority(b) - secretScanPriority(a);
|
|
269
|
+
return priorityDelta || a.localeCompare(b);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const fetched = await mapWithConcurrency(
|
|
273
|
+
candidatePaths,
|
|
274
|
+
16,
|
|
275
|
+
async (path) => {
|
|
276
|
+
const content = await fetchRawFile(owner, repo, ref, path);
|
|
277
|
+
return { path, content };
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const files: RepoFile[] = fetched
|
|
282
|
+
.filter(
|
|
283
|
+
(f): f is { path: string; content: string } =>
|
|
284
|
+
f.content !== null && f.content.length > 0,
|
|
285
|
+
)
|
|
286
|
+
.map((f) => ({ path: f.path, content: f.content }));
|
|
287
|
+
|
|
288
|
+
const workflows = fileTree.filter((p) => p.startsWith(`${WORKFLOW_DIR}/`));
|
|
289
|
+
const hasDependabot = DEPENDABOT_PATHS.some((p) => fileTree.includes(p));
|
|
290
|
+
const hasWorkflows = workflows.length > 0;
|
|
291
|
+
const hasIssueTemplate = fileTree.some((p) =>
|
|
292
|
+
p.startsWith(`${ISSUE_TEMPLATE_DIR}/`),
|
|
293
|
+
);
|
|
294
|
+
const hasCodeowners =
|
|
295
|
+
fileTree.includes(".github/CODEOWNERS") ||
|
|
296
|
+
fileTree.includes("CODEOWNERS") ||
|
|
297
|
+
fileTree.includes("docs/CODEOWNERS");
|
|
298
|
+
const hasPullRequestTemplate =
|
|
299
|
+
fileTree.includes(".github/PULL_REQUEST_TEMPLATE.md") ||
|
|
300
|
+
fileTree.includes("PULL_REQUEST_TEMPLATE.md") ||
|
|
301
|
+
fileTree.includes(".github/PULL_REQUEST_TEMPLATE/") ||
|
|
302
|
+
fileTree.includes("docs/PULL_REQUEST_TEMPLATE.md");
|
|
303
|
+
const hasDockerfile = fileTree.some(
|
|
304
|
+
(p) => p === "Dockerfile" || /^Dockerfile\.[^/]+$/.test(p),
|
|
305
|
+
);
|
|
306
|
+
const hasDockerignore = fileTree.includes(".dockerignore");
|
|
307
|
+
const hasChangelog = fileTree.some(
|
|
308
|
+
(p) =>
|
|
309
|
+
p === "CHANGELOG.md" ||
|
|
310
|
+
p === "CHANGELOG" ||
|
|
311
|
+
p === "HISTORY.md" ||
|
|
312
|
+
p === "RELEASES.md" ||
|
|
313
|
+
/^CHANGELOG\.[^/]+$/.test(p),
|
|
314
|
+
);
|
|
315
|
+
const hasContributing = fileTree.includes("CONTRIBUTING.md");
|
|
316
|
+
const hasCodeOfConduct =
|
|
317
|
+
fileTree.includes("CODE_OF_CONDUCT.md") ||
|
|
318
|
+
fileTree.includes(".github/CODE_OF_CONDUCT.md");
|
|
319
|
+
|
|
320
|
+
const presentLockfiles = LOCKFILES.filter((p) => fileTree.includes(p));
|
|
321
|
+
const primaryLockfile = presentLockfiles[0] ?? null;
|
|
322
|
+
const extraLockfiles = presentLockfiles.slice(1);
|
|
323
|
+
|
|
324
|
+
const metadata: RepoMetadata = {
|
|
325
|
+
owner,
|
|
326
|
+
repo,
|
|
327
|
+
defaultBranch: ref,
|
|
328
|
+
description: meta.description,
|
|
329
|
+
stars: meta.stargazers_count,
|
|
330
|
+
forks: meta.forks_count,
|
|
331
|
+
openIssues: meta.open_issues_count,
|
|
332
|
+
isPrivate: meta.private,
|
|
333
|
+
htmlUrl: meta.html_url,
|
|
334
|
+
homepageUrl: meta.homepage?.trim() || null,
|
|
335
|
+
topics: meta.topics ?? [],
|
|
336
|
+
archived: meta.archived ?? false,
|
|
337
|
+
isTemplate: meta.is_template ?? false,
|
|
338
|
+
language: meta.language ?? null,
|
|
339
|
+
sizeKb: meta.size,
|
|
340
|
+
pushedAt: meta.pushed_at,
|
|
341
|
+
updatedAt: meta.updated_at,
|
|
342
|
+
createdAt: meta.created_at,
|
|
343
|
+
licenseSpdxId: meta.license?.spdx_id ?? null,
|
|
344
|
+
licenseName: meta.license?.name ?? null,
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
metadata,
|
|
349
|
+
files,
|
|
350
|
+
fileTree,
|
|
351
|
+
workflows,
|
|
352
|
+
hasDependabot,
|
|
353
|
+
hasWorkflows,
|
|
354
|
+
hasIssueTemplate,
|
|
355
|
+
hasCodeowners,
|
|
356
|
+
hasPullRequestTemplate,
|
|
357
|
+
hasDockerfile,
|
|
358
|
+
hasDockerignore,
|
|
359
|
+
hasChangelog,
|
|
360
|
+
hasContributing,
|
|
361
|
+
hasCodeOfConduct,
|
|
362
|
+
primaryLockfile,
|
|
363
|
+
extraLockfiles,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { RepoData, RepoFile } from "./types";
|
|
5
|
+
import { isLikelySecretScanPath } from "./scan-targets";
|
|
6
|
+
|
|
7
|
+
const LOCAL_SKIP_DIRS = new Set([
|
|
8
|
+
".git",
|
|
9
|
+
".next",
|
|
10
|
+
"node_modules",
|
|
11
|
+
"dist",
|
|
12
|
+
"build",
|
|
13
|
+
"out",
|
|
14
|
+
"coverage",
|
|
15
|
+
"vendor",
|
|
16
|
+
".venv",
|
|
17
|
+
"venv",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
interface LocalLoadOptions {
|
|
21
|
+
includeHistory?: boolean;
|
|
22
|
+
maxHistoryCommits?: number;
|
|
23
|
+
maxHistoryFilesPerCommit?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function walk(root: string, dir: string, files: RepoFile[]): Promise<void> {
|
|
27
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const fullPath = path.join(dir, entry.name);
|
|
30
|
+
const relPath = path.relative(root, fullPath).replace(/\\/g, "/");
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
if (LOCAL_SKIP_DIRS.has(entry.name)) continue;
|
|
33
|
+
await walk(root, fullPath, files);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!entry.isFile()) continue;
|
|
37
|
+
let stat;
|
|
38
|
+
try {
|
|
39
|
+
stat = await fs.stat(fullPath);
|
|
40
|
+
} catch {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (!isLikelySecretScanPath(relPath, stat.size) && !isImportantLocalFile(relPath)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const content = await fs.readFile(fullPath, "utf8");
|
|
48
|
+
files.push({ path: relPath, content });
|
|
49
|
+
} catch {
|
|
50
|
+
// Binary or unreadable files are ignored.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isImportantLocalFile(relPath: string): boolean {
|
|
56
|
+
return [
|
|
57
|
+
"README.md",
|
|
58
|
+
"package.json",
|
|
59
|
+
".gitignore",
|
|
60
|
+
"SECURITY.md",
|
|
61
|
+
"LICENSE",
|
|
62
|
+
"Dockerfile",
|
|
63
|
+
".dockerignore",
|
|
64
|
+
"docker-compose.yml",
|
|
65
|
+
"docker-compose.yaml",
|
|
66
|
+
".reposecignore",
|
|
67
|
+
"reposec-baseline.json",
|
|
68
|
+
".reposec-baseline.json",
|
|
69
|
+
].includes(relPath);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function git(root: string, args: string[]): string {
|
|
73
|
+
return execFileSync("git", args, {
|
|
74
|
+
cwd: root,
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
77
|
+
}).trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function loadHistoryFiles(
|
|
81
|
+
root: string,
|
|
82
|
+
maxCommits: number,
|
|
83
|
+
maxFilesPerCommit: number,
|
|
84
|
+
): RepoFile[] {
|
|
85
|
+
let commits: string[] = [];
|
|
86
|
+
try {
|
|
87
|
+
commits = git(root, ["rev-list", "--all", `--max-count=${maxCommits}`])
|
|
88
|
+
.split(/\r?\n/)
|
|
89
|
+
.filter(Boolean);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const files: RepoFile[] = [];
|
|
95
|
+
for (const commit of commits) {
|
|
96
|
+
let names: string[] = [];
|
|
97
|
+
try {
|
|
98
|
+
names = git(root, ["ls-tree", "-r", "--name-only", commit])
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.filter((name) => isLikelySecretScanPath(name))
|
|
101
|
+
.slice(0, maxFilesPerCommit);
|
|
102
|
+
} catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const name of names) {
|
|
106
|
+
try {
|
|
107
|
+
const content = git(root, ["show", `${commit}:${name}`]);
|
|
108
|
+
if (!content) continue;
|
|
109
|
+
files.push({
|
|
110
|
+
path: `git-history/${commit.slice(0, 12)}/${name}`,
|
|
111
|
+
content,
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
// Deleted/binary/unreadable historical blobs are ignored.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return files;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function loadLocalRepo(
|
|
122
|
+
rootInput: string,
|
|
123
|
+
options: LocalLoadOptions = {},
|
|
124
|
+
): Promise<RepoData> {
|
|
125
|
+
const root = path.resolve(rootInput);
|
|
126
|
+
const files: RepoFile[] = [];
|
|
127
|
+
await walk(root, root, files);
|
|
128
|
+
|
|
129
|
+
if (options.includeHistory) {
|
|
130
|
+
files.push(
|
|
131
|
+
...loadHistoryFiles(
|
|
132
|
+
root,
|
|
133
|
+
options.maxHistoryCommits ?? 50,
|
|
134
|
+
options.maxHistoryFilesPerCommit ?? 250,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fileTree = files.map((file) => file.path);
|
|
140
|
+
const repoName = path.basename(root);
|
|
141
|
+
const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lockb"].filter(
|
|
142
|
+
(p) => fileTree.includes(p),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
metadata: {
|
|
147
|
+
owner: "local",
|
|
148
|
+
repo: repoName,
|
|
149
|
+
defaultBranch: "local",
|
|
150
|
+
description: "Local repository scan",
|
|
151
|
+
stars: 0,
|
|
152
|
+
forks: 0,
|
|
153
|
+
openIssues: 0,
|
|
154
|
+
isPrivate: true,
|
|
155
|
+
htmlUrl: `file://${root.replace(/\\/g, "/")}`,
|
|
156
|
+
language: null,
|
|
157
|
+
sizeKb: undefined,
|
|
158
|
+
},
|
|
159
|
+
files,
|
|
160
|
+
fileTree,
|
|
161
|
+
workflows: fileTree.filter((p) => p.startsWith(".github/workflows/")),
|
|
162
|
+
hasDependabot:
|
|
163
|
+
fileTree.includes(".github/dependabot.yml") ||
|
|
164
|
+
fileTree.includes(".github/dependabot.yaml"),
|
|
165
|
+
hasWorkflows: fileTree.some((p) => p.startsWith(".github/workflows/")),
|
|
166
|
+
hasIssueTemplate: fileTree.some((p) => p.startsWith(".github/ISSUE_TEMPLATE/")),
|
|
167
|
+
hasCodeowners:
|
|
168
|
+
fileTree.includes(".github/CODEOWNERS") || fileTree.includes("CODEOWNERS"),
|
|
169
|
+
hasPullRequestTemplate:
|
|
170
|
+
fileTree.includes(".github/PULL_REQUEST_TEMPLATE.md") ||
|
|
171
|
+
fileTree.includes("PULL_REQUEST_TEMPLATE.md"),
|
|
172
|
+
hasDockerfile: fileTree.some((p) => p === "Dockerfile" || /^Dockerfile\.[^/]+$/.test(p)),
|
|
173
|
+
hasDockerignore: fileTree.includes(".dockerignore"),
|
|
174
|
+
hasChangelog: fileTree.some((p) => p === "CHANGELOG.md" || p === "CHANGELOG"),
|
|
175
|
+
hasContributing: fileTree.includes("CONTRIBUTING.md"),
|
|
176
|
+
hasCodeOfConduct:
|
|
177
|
+
fileTree.includes("CODE_OF_CONDUCT.md") ||
|
|
178
|
+
fileTree.includes(".github/CODE_OF_CONDUCT.md"),
|
|
179
|
+
primaryLockfile: lockfiles[0] ?? null,
|
|
180
|
+
extraLockfiles: lockfiles.slice(1),
|
|
181
|
+
};
|
|
182
|
+
}
|