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/scanner.ts
ADDED
|
@@ -0,0 +1,2015 @@
|
|
|
1
|
+
import { maskSecret } from "./utils";
|
|
2
|
+
import { fingerprintSecret } from "./fingerprint";
|
|
3
|
+
import { applyRepoBaseline } from "./baseline";
|
|
4
|
+
import {
|
|
5
|
+
isLikelySecretScanPath,
|
|
6
|
+
secretScanPriority,
|
|
7
|
+
} from "./scan-targets";
|
|
8
|
+
import type {
|
|
9
|
+
CategoryBreakdown,
|
|
10
|
+
CheckResult,
|
|
11
|
+
CheckStatus,
|
|
12
|
+
FileGroup,
|
|
13
|
+
Finding,
|
|
14
|
+
FindingCategory,
|
|
15
|
+
FindingConfidence,
|
|
16
|
+
RepoData,
|
|
17
|
+
RepoFile,
|
|
18
|
+
ScanSummary,
|
|
19
|
+
Severity,
|
|
20
|
+
} from "./types";
|
|
21
|
+
import {
|
|
22
|
+
fileContainsAnyNeedle,
|
|
23
|
+
isCommentedLine,
|
|
24
|
+
isLikelyPlaceholder,
|
|
25
|
+
SECRET_PATTERNS,
|
|
26
|
+
SEVERITY_RANK,
|
|
27
|
+
SEVERITY_WEIGHT,
|
|
28
|
+
} from "./rules";
|
|
29
|
+
|
|
30
|
+
interface ScanContext {
|
|
31
|
+
repo: RepoData;
|
|
32
|
+
findings: Finding[];
|
|
33
|
+
checks: CheckResult[];
|
|
34
|
+
filesChecked: Set<string>;
|
|
35
|
+
filesMissing: string[];
|
|
36
|
+
secretCandidates: SecretCandidate[];
|
|
37
|
+
collectSecretCandidates: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SecretCandidate {
|
|
41
|
+
findingId: string;
|
|
42
|
+
patternName: string;
|
|
43
|
+
value: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findFile(files: RepoFile[], path: string): RepoFile | undefined {
|
|
47
|
+
return files.find((f) => f.path === path);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getLines(content: string): string[] {
|
|
51
|
+
return content.split(/\r?\n/);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeFinding(
|
|
55
|
+
id: string,
|
|
56
|
+
title: string,
|
|
57
|
+
description: string,
|
|
58
|
+
severity: Severity,
|
|
59
|
+
category: FindingCategory,
|
|
60
|
+
fix: string,
|
|
61
|
+
extras: Partial<Finding> = {},
|
|
62
|
+
): Finding {
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
title,
|
|
66
|
+
description,
|
|
67
|
+
severity,
|
|
68
|
+
category,
|
|
69
|
+
fix,
|
|
70
|
+
...extras,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function confidenceForSecretPattern(name: string): FindingConfidence {
|
|
75
|
+
const lower = name.toLowerCase();
|
|
76
|
+
if (lower.includes("informational") || lower.includes("public key")) {
|
|
77
|
+
return "low";
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
lower.includes("generic") ||
|
|
81
|
+
lower.includes("terraform variable") ||
|
|
82
|
+
lower.includes("jwt secret assignment")
|
|
83
|
+
) {
|
|
84
|
+
return "medium";
|
|
85
|
+
}
|
|
86
|
+
return "high";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function addCheck(
|
|
90
|
+
ctx: ScanContext,
|
|
91
|
+
id: string,
|
|
92
|
+
category: FindingCategory,
|
|
93
|
+
title: string,
|
|
94
|
+
status: CheckStatus,
|
|
95
|
+
message: string,
|
|
96
|
+
extras: { file?: string; line?: number } = {},
|
|
97
|
+
): void {
|
|
98
|
+
ctx.checks.push({ id, category, title, status, message, ...extras });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function recordCheck(
|
|
102
|
+
ctx: ScanContext,
|
|
103
|
+
id: string,
|
|
104
|
+
category: FindingCategory,
|
|
105
|
+
title: string,
|
|
106
|
+
file: string | undefined,
|
|
107
|
+
findings: Finding[],
|
|
108
|
+
passMessage: string,
|
|
109
|
+
): void {
|
|
110
|
+
for (const f of findings) ctx.findings.push(f);
|
|
111
|
+
if (findings.length === 0) {
|
|
112
|
+
addCheck(ctx, id, category, title, "pass", passMessage, { file });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
let highestRank = 0;
|
|
116
|
+
let highestSev: Severity = "info";
|
|
117
|
+
for (const f of findings) {
|
|
118
|
+
const r = SEVERITY_RANK[f.severity];
|
|
119
|
+
if (r > highestRank) {
|
|
120
|
+
highestRank = r;
|
|
121
|
+
highestSev = f.severity;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const status: CheckStatus =
|
|
125
|
+
highestSev === "info" ? "info" : highestSev === "low" ? "warn" : "fail";
|
|
126
|
+
addCheck(ctx, id, category, title, status, findings[0].description, { file });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function maskLine(line: string): string {
|
|
130
|
+
return line.replace(
|
|
131
|
+
/(?:api[_-]?key|apikey|secret|token|password|passwd|pwd)\s*[:=]\s*["']?([^"'\s]+)/gi,
|
|
132
|
+
(_m, val: string) => {
|
|
133
|
+
if (isLikelyPlaceholder(val)) return val;
|
|
134
|
+
return maskSecret(val);
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isInsideString(line: string, matchIndex: number): boolean {
|
|
140
|
+
const before = line.slice(0, matchIndex);
|
|
141
|
+
const singleQuotes = (before.match(/'/g) ?? []).length;
|
|
142
|
+
const doubleQuotes = (before.match(/"/g) ?? []).length;
|
|
143
|
+
const backticks = (before.match(/`/g) ?? []).length;
|
|
144
|
+
return singleQuotes % 2 === 1 || doubleQuotes % 2 === 1 || backticks % 2 === 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isTestFile(path: string): boolean {
|
|
148
|
+
return (
|
|
149
|
+
/\.(test|spec)\.[cm]?[jt]sx?$/.test(path) ||
|
|
150
|
+
/(^|\/)__tests__\//.test(path) ||
|
|
151
|
+
/(^|\/)__mocks__\//.test(path) ||
|
|
152
|
+
/(^|\/)__fixtures__\//.test(path) ||
|
|
153
|
+
/(^|\/)test\//.test(path) ||
|
|
154
|
+
/(^|\/)tests\//.test(path) ||
|
|
155
|
+
/(^|\/)fixtures?\//.test(path)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readGitignorePatterns(content: string): string[] {
|
|
160
|
+
return content
|
|
161
|
+
.split(/\r?\n/)
|
|
162
|
+
.map((l) => l.trim())
|
|
163
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function gitignoreCoversAny(
|
|
167
|
+
content: string,
|
|
168
|
+
matchers: Array<(pattern: string) => boolean>,
|
|
169
|
+
): boolean {
|
|
170
|
+
for (const raw of readGitignorePatterns(content)) {
|
|
171
|
+
let pattern = raw;
|
|
172
|
+
if (pattern.startsWith("!")) continue;
|
|
173
|
+
pattern = pattern.replace(/^(?:\/?\*\*\/)+/, "").replace(/^\//, "");
|
|
174
|
+
if (pattern.endsWith("/")) pattern = pattern.slice(0, -1);
|
|
175
|
+
for (const matcher of matchers) {
|
|
176
|
+
if (matcher(pattern)) return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const envMatchers: Array<(p: string) => boolean> = [
|
|
183
|
+
(p) => p === ".env",
|
|
184
|
+
(p) => p === "**/.env",
|
|
185
|
+
(p) => p === ".env*",
|
|
186
|
+
(p) => p === "**/.env*",
|
|
187
|
+
(p) => p.startsWith(".env."),
|
|
188
|
+
(p) => p.startsWith("**/.env."),
|
|
189
|
+
(p) => p.startsWith(".env/"),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const buildOutputMatchers: Array<(p: string) => boolean> = [
|
|
193
|
+
(p) => p === "dist" || p.startsWith("dist/"),
|
|
194
|
+
(p) => p === "build" || p.startsWith("build/"),
|
|
195
|
+
(p) => p === "out" || p.startsWith("out/"),
|
|
196
|
+
(p) => p === ".next" || p.startsWith(".next/"),
|
|
197
|
+
(p) => p === ".nuxt" || p.startsWith(".nuxt/"),
|
|
198
|
+
(p) => p === "*.output" || p.startsWith("*.output/"),
|
|
199
|
+
(p) => p === "coverage" || p.startsWith("coverage/"),
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
const nodeModulesMatchers: Array<(p: string) => boolean> = [
|
|
203
|
+
(p) => p === "node_modules",
|
|
204
|
+
(p) => p === "node_modules/",
|
|
205
|
+
(p) => p === "**/node_modules",
|
|
206
|
+
(p) => p === "**/node_modules/**",
|
|
207
|
+
(p) => p.startsWith("node_modules/"),
|
|
208
|
+
(p) => p.startsWith("**/node_modules/"),
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
function hasHeadingContaining(
|
|
212
|
+
content: string,
|
|
213
|
+
keywords: string[],
|
|
214
|
+
): { found: boolean; matchedHeading?: string; keyword?: string } {
|
|
215
|
+
for (const line of content.split(/\r?\n/)) {
|
|
216
|
+
const m = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
217
|
+
if (!m) continue;
|
|
218
|
+
const heading = m[2].trim();
|
|
219
|
+
const lowered = heading.toLowerCase();
|
|
220
|
+
for (const kw of keywords) {
|
|
221
|
+
if (lowered.includes(kw.toLowerCase())) {
|
|
222
|
+
return { found: true, matchedHeading: heading, keyword: kw };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { found: false };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function missingFile(ctx: ScanContext, path: string): void {
|
|
230
|
+
ctx.filesMissing.push(path);
|
|
231
|
+
ctx.filesChecked.add(path);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function presentFile(ctx: ScanContext, path: string): void {
|
|
235
|
+
ctx.filesChecked.add(path);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function checkEnvironment(ctx: ScanContext): void {
|
|
239
|
+
const envFiles = [".env", ".env.local", ".env.production", ".env.development"];
|
|
240
|
+
const exposed: Finding[] = [];
|
|
241
|
+
for (const env of envFiles) {
|
|
242
|
+
const f = findFile(ctx.repo.files, env);
|
|
243
|
+
if (!f) {
|
|
244
|
+
missingFile(ctx, env);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
presentFile(ctx, env);
|
|
248
|
+
const lines = getLines(f.content);
|
|
249
|
+
const sample = lines
|
|
250
|
+
.filter((l) => l.trim() && !isCommentedLine(l, "env"))
|
|
251
|
+
.slice(0, 3)
|
|
252
|
+
.map(maskLine)
|
|
253
|
+
.join(" | ");
|
|
254
|
+
exposed.push(
|
|
255
|
+
makeFinding(
|
|
256
|
+
`env-exposed-${env}`,
|
|
257
|
+
`Exposed environment file: ${env}`,
|
|
258
|
+
`A ${env} file is committed to the repository. This file likely contains secrets, tokens, or database credentials. Remove it from the repo and rotate any real values.`,
|
|
259
|
+
"critical",
|
|
260
|
+
"environment",
|
|
261
|
+
`Delete ${env} from the repository, add it to .gitignore, purge it from git history (git filter-repo), and rotate every value that was inside.`,
|
|
262
|
+
{
|
|
263
|
+
file: env,
|
|
264
|
+
evidence: sample || "non-empty file present",
|
|
265
|
+
fixPrompt: `Remove ${env} from the repository, ensure .gitignore contains the right patterns, and rewrite the file with placeholder values.`,
|
|
266
|
+
},
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
recordCheck(
|
|
272
|
+
ctx,
|
|
273
|
+
"env-exposure",
|
|
274
|
+
"environment",
|
|
275
|
+
"No exposed .env files in the repository",
|
|
276
|
+
undefined,
|
|
277
|
+
exposed,
|
|
278
|
+
"No .env files are committed to the repository.",
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const envExample = findFile(ctx.repo.files, ".env.example");
|
|
282
|
+
if (!envExample) {
|
|
283
|
+
missingFile(ctx, ".env.example");
|
|
284
|
+
addCheck(
|
|
285
|
+
ctx,
|
|
286
|
+
"env-example",
|
|
287
|
+
"environment",
|
|
288
|
+
".env.example present",
|
|
289
|
+
"fail",
|
|
290
|
+
"No .env.example file is present. New contributors cannot tell which environment variables to set.",
|
|
291
|
+
{ file: ".env.example" },
|
|
292
|
+
);
|
|
293
|
+
ctx.findings.push(
|
|
294
|
+
makeFinding(
|
|
295
|
+
"env-example-missing",
|
|
296
|
+
"Missing .env.example",
|
|
297
|
+
"No .env.example file is present. New contributors cannot tell which environment variables to set.",
|
|
298
|
+
"medium",
|
|
299
|
+
"environment",
|
|
300
|
+
"Add an .env.example file with placeholder values (KEY=value) for every variable the app reads at runtime.",
|
|
301
|
+
{
|
|
302
|
+
fixPrompt:
|
|
303
|
+
"Create an .env.example file with placeholder values for every environment variable used by the app. Do not put real secrets in it.",
|
|
304
|
+
},
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
} else {
|
|
308
|
+
presentFile(ctx, ".env.example");
|
|
309
|
+
addCheck(
|
|
310
|
+
ctx,
|
|
311
|
+
"env-example",
|
|
312
|
+
"environment",
|
|
313
|
+
".env.example present",
|
|
314
|
+
"pass",
|
|
315
|
+
".env.example is present, contributors can see which keys to set.",
|
|
316
|
+
{ file: ".env.example" },
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function checkGitignore(ctx: ScanContext): void {
|
|
322
|
+
const gi = findFile(ctx.repo.files, ".gitignore");
|
|
323
|
+
if (!gi) {
|
|
324
|
+
missingFile(ctx, ".gitignore");
|
|
325
|
+
addCheck(
|
|
326
|
+
ctx,
|
|
327
|
+
"gitignore",
|
|
328
|
+
"environment",
|
|
329
|
+
".gitignore present",
|
|
330
|
+
"fail",
|
|
331
|
+
"No .gitignore file is present. Build artifacts, dependencies, and local files may be committed by accident.",
|
|
332
|
+
{ file: ".gitignore" },
|
|
333
|
+
);
|
|
334
|
+
ctx.findings.push(
|
|
335
|
+
makeFinding(
|
|
336
|
+
"gitignore-missing",
|
|
337
|
+
"Missing .gitignore",
|
|
338
|
+
"No .gitignore file is present. Build artifacts, dependencies, and local files may be committed by accident.",
|
|
339
|
+
"high",
|
|
340
|
+
"environment",
|
|
341
|
+
"Add a .gitignore suited to the project stack (Node, Python, Go, etc.) and include .env, node_modules, dist, build, and IDE folders.",
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
presentFile(ctx, ".gitignore");
|
|
347
|
+
|
|
348
|
+
const checks: Array<{
|
|
349
|
+
id: string;
|
|
350
|
+
title: string;
|
|
351
|
+
severity: Severity;
|
|
352
|
+
fix: string;
|
|
353
|
+
covered: boolean;
|
|
354
|
+
}> = [
|
|
355
|
+
{
|
|
356
|
+
id: "gitignore-env",
|
|
357
|
+
title: ".gitignore covers .env files",
|
|
358
|
+
severity: "high",
|
|
359
|
+
fix: "Add an entry like `.env`, `.env*`, or `.env.*` to .gitignore (and `!.env.example` if you ship a template).",
|
|
360
|
+
covered: gitignoreCoversAny(gi.content, envMatchers),
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "gitignore-node-modules",
|
|
364
|
+
title: ".gitignore ignores node_modules",
|
|
365
|
+
severity: "low",
|
|
366
|
+
fix: "Add `node_modules/` to .gitignore so dependencies are not committed.",
|
|
367
|
+
covered: gitignoreCoversAny(gi.content, nodeModulesMatchers),
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
id: "gitignore-build",
|
|
371
|
+
title: ".gitignore ignores build output",
|
|
372
|
+
severity: "low",
|
|
373
|
+
fix: "Add entries for your build output (`dist/`, `build/`, `out/`, `.next/`, `coverage/`, etc.) to .gitignore.",
|
|
374
|
+
covered: gitignoreCoversAny(gi.content, buildOutputMatchers),
|
|
375
|
+
},
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
for (const check of checks) {
|
|
379
|
+
if (!check.covered) {
|
|
380
|
+
addCheck(
|
|
381
|
+
ctx,
|
|
382
|
+
check.id,
|
|
383
|
+
"environment",
|
|
384
|
+
check.title,
|
|
385
|
+
"fail",
|
|
386
|
+
"Your .gitignore is missing an important entry. This can leak secrets or bloat the repository.",
|
|
387
|
+
{ file: ".gitignore" },
|
|
388
|
+
);
|
|
389
|
+
ctx.findings.push(
|
|
390
|
+
makeFinding(
|
|
391
|
+
check.id,
|
|
392
|
+
check.title
|
|
393
|
+
.replace(" ignores", " should ignore")
|
|
394
|
+
.replace(" covers", " should cover"),
|
|
395
|
+
"Your .gitignore is missing an important entry. This can leak secrets or bloat the repository.",
|
|
396
|
+
check.severity,
|
|
397
|
+
"environment",
|
|
398
|
+
check.fix,
|
|
399
|
+
{ file: ".gitignore" },
|
|
400
|
+
),
|
|
401
|
+
);
|
|
402
|
+
} else {
|
|
403
|
+
addCheck(
|
|
404
|
+
ctx,
|
|
405
|
+
check.id,
|
|
406
|
+
"environment",
|
|
407
|
+
check.title,
|
|
408
|
+
"pass",
|
|
409
|
+
"Pattern is present in .gitignore.",
|
|
410
|
+
{ file: ".gitignore" },
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function checkDocumentation(ctx: ScanContext): void {
|
|
417
|
+
const readme = findFile(ctx.repo.files, "README.md");
|
|
418
|
+
if (!readme) {
|
|
419
|
+
missingFile(ctx, "README.md");
|
|
420
|
+
addCheck(
|
|
421
|
+
ctx,
|
|
422
|
+
"readme",
|
|
423
|
+
"documentation",
|
|
424
|
+
"README.md present",
|
|
425
|
+
"fail",
|
|
426
|
+
"There is no README. New users and contributors will not know how to install, run, or contribute.",
|
|
427
|
+
{ file: "README.md" },
|
|
428
|
+
);
|
|
429
|
+
ctx.findings.push(
|
|
430
|
+
makeFinding(
|
|
431
|
+
"readme-missing",
|
|
432
|
+
"Missing README.md",
|
|
433
|
+
"There is no README. New users and contributors will not know how to install, run, or contribute.",
|
|
434
|
+
"medium",
|
|
435
|
+
"documentation",
|
|
436
|
+
"Add a README.md with a project description, install steps, usage, and a security section.",
|
|
437
|
+
),
|
|
438
|
+
);
|
|
439
|
+
} else {
|
|
440
|
+
presentFile(ctx, "README.md");
|
|
441
|
+
addCheck(
|
|
442
|
+
ctx,
|
|
443
|
+
"readme",
|
|
444
|
+
"documentation",
|
|
445
|
+
"README.md present",
|
|
446
|
+
"pass",
|
|
447
|
+
"README.md is present in the repository root.",
|
|
448
|
+
{ file: "README.md" },
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
const sections: Array<{
|
|
452
|
+
keywords: string[];
|
|
453
|
+
id: string;
|
|
454
|
+
title: string;
|
|
455
|
+
fix: string;
|
|
456
|
+
severity: Severity;
|
|
457
|
+
}> = [
|
|
458
|
+
{
|
|
459
|
+
keywords: [
|
|
460
|
+
"install",
|
|
461
|
+
"installation",
|
|
462
|
+
"setup",
|
|
463
|
+
"getting started",
|
|
464
|
+
"quickstart",
|
|
465
|
+
"quick start",
|
|
466
|
+
"how to start",
|
|
467
|
+
"how to use",
|
|
468
|
+
"build",
|
|
469
|
+
"running",
|
|
470
|
+
"run locally",
|
|
471
|
+
"local development",
|
|
472
|
+
"development",
|
|
473
|
+
],
|
|
474
|
+
id: "readme-setup",
|
|
475
|
+
title: "README documents setup steps",
|
|
476
|
+
fix: "Add an Install / Getting Started section with the exact commands a new user has to run.",
|
|
477
|
+
severity: "low",
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
keywords: [
|
|
481
|
+
"environment variable",
|
|
482
|
+
"env var",
|
|
483
|
+
"env vars",
|
|
484
|
+
".env",
|
|
485
|
+
"configuration",
|
|
486
|
+
"config",
|
|
487
|
+
"settings",
|
|
488
|
+
],
|
|
489
|
+
id: "readme-env",
|
|
490
|
+
title: "README documents environment variables",
|
|
491
|
+
fix: "Add a section listing every environment variable the app reads, with a short description for each.",
|
|
492
|
+
severity: "low",
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
keywords: [
|
|
496
|
+
"security",
|
|
497
|
+
"security policy",
|
|
498
|
+
"reporting",
|
|
499
|
+
"responsible disclosure",
|
|
500
|
+
"vulnerability",
|
|
501
|
+
],
|
|
502
|
+
id: "readme-security",
|
|
503
|
+
title: "README has a security section",
|
|
504
|
+
fix: "Add a Security section that links to SECURITY.md and explains how to report vulnerabilities.",
|
|
505
|
+
severity: "low",
|
|
506
|
+
},
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
for (const section of sections) {
|
|
510
|
+
const result = hasHeadingContaining(readme.content, section.keywords);
|
|
511
|
+
if (!result.found) {
|
|
512
|
+
addCheck(
|
|
513
|
+
ctx,
|
|
514
|
+
section.id,
|
|
515
|
+
"documentation",
|
|
516
|
+
section.title,
|
|
517
|
+
section.severity === "low" ? "warn" : "fail",
|
|
518
|
+
"Readers will not know how to set up the project or report security issues without this section.",
|
|
519
|
+
{ file: "README.md" },
|
|
520
|
+
);
|
|
521
|
+
ctx.findings.push(
|
|
522
|
+
makeFinding(
|
|
523
|
+
section.id,
|
|
524
|
+
section.title.replace(" documents", " missing").replace(" has a security", " has no security"),
|
|
525
|
+
"Readers will not know how to set up the project or report security issues without this section.",
|
|
526
|
+
section.severity,
|
|
527
|
+
"documentation",
|
|
528
|
+
section.fix,
|
|
529
|
+
{ file: "README.md" },
|
|
530
|
+
),
|
|
531
|
+
);
|
|
532
|
+
} else {
|
|
533
|
+
addCheck(
|
|
534
|
+
ctx,
|
|
535
|
+
section.id,
|
|
536
|
+
"documentation",
|
|
537
|
+
section.title,
|
|
538
|
+
"pass",
|
|
539
|
+
`Found a "${result.matchedHeading}" heading in the README.`,
|
|
540
|
+
{ file: "README.md" },
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (!findFile(ctx.repo.files, "SECURITY.md")) {
|
|
547
|
+
missingFile(ctx, "SECURITY.md");
|
|
548
|
+
addCheck(
|
|
549
|
+
ctx,
|
|
550
|
+
"security-md",
|
|
551
|
+
"documentation",
|
|
552
|
+
"SECURITY.md present",
|
|
553
|
+
"fail",
|
|
554
|
+
"There is no SECURITY.md. Security researchers have no clear way to report vulnerabilities to you.",
|
|
555
|
+
{ file: "SECURITY.md" },
|
|
556
|
+
);
|
|
557
|
+
ctx.findings.push(
|
|
558
|
+
makeFinding(
|
|
559
|
+
"security-md-missing",
|
|
560
|
+
"Missing SECURITY.md",
|
|
561
|
+
"There is no SECURITY.md. Security researchers have no clear way to report vulnerabilities to you.",
|
|
562
|
+
"medium",
|
|
563
|
+
"documentation",
|
|
564
|
+
"Add a SECURITY.md with supported versions, a contact method (email or GitHub Security Advisories), and a response timeline.",
|
|
565
|
+
{
|
|
566
|
+
fixPrompt:
|
|
567
|
+
"Create a SECURITY.md file following GitHub's community health standards. Include supported versions, how to report a vulnerability, and a reasonable response timeline.",
|
|
568
|
+
},
|
|
569
|
+
),
|
|
570
|
+
);
|
|
571
|
+
} else {
|
|
572
|
+
presentFile(ctx, "SECURITY.md");
|
|
573
|
+
addCheck(
|
|
574
|
+
ctx,
|
|
575
|
+
"security-md",
|
|
576
|
+
"documentation",
|
|
577
|
+
"SECURITY.md present",
|
|
578
|
+
"pass",
|
|
579
|
+
"SECURITY.md is present, vulnerability disclosure is documented.",
|
|
580
|
+
{ file: "SECURITY.md" },
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!findFile(ctx.repo.files, "LICENSE")) {
|
|
585
|
+
missingFile(ctx, "LICENSE");
|
|
586
|
+
addCheck(
|
|
587
|
+
ctx,
|
|
588
|
+
"license",
|
|
589
|
+
"documentation",
|
|
590
|
+
"LICENSE present",
|
|
591
|
+
"warn",
|
|
592
|
+
"No LICENSE file is present. Users and contributors cannot tell what they are allowed to do with the code.",
|
|
593
|
+
{ file: "LICENSE" },
|
|
594
|
+
);
|
|
595
|
+
ctx.findings.push(
|
|
596
|
+
makeFinding(
|
|
597
|
+
"license-missing",
|
|
598
|
+
"Missing LICENSE",
|
|
599
|
+
"No LICENSE file is present. Users and contributors cannot tell what they are allowed to do with the code.",
|
|
600
|
+
"low",
|
|
601
|
+
"documentation",
|
|
602
|
+
"Add a LICENSE file (MIT, Apache-2.0, etc.) and reference it from the README.",
|
|
603
|
+
),
|
|
604
|
+
);
|
|
605
|
+
} else {
|
|
606
|
+
presentFile(ctx, "LICENSE");
|
|
607
|
+
addCheck(
|
|
608
|
+
ctx,
|
|
609
|
+
"license",
|
|
610
|
+
"documentation",
|
|
611
|
+
"LICENSE present",
|
|
612
|
+
"pass",
|
|
613
|
+
"LICENSE file is present in the repository root.",
|
|
614
|
+
{ file: "LICENSE" },
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function checkPackage(ctx: ScanContext): void {
|
|
620
|
+
const pkg = findFile(ctx.repo.files, "package.json");
|
|
621
|
+
if (!pkg) {
|
|
622
|
+
missingFile(ctx, "package.json");
|
|
623
|
+
addCheck(
|
|
624
|
+
ctx,
|
|
625
|
+
"package-json",
|
|
626
|
+
"package",
|
|
627
|
+
"package.json present",
|
|
628
|
+
"skip",
|
|
629
|
+
"No package.json was found, so Node-based checks were skipped.",
|
|
630
|
+
{ file: "package.json" },
|
|
631
|
+
);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
presentFile(ctx, "package.json");
|
|
635
|
+
|
|
636
|
+
let parsed: {
|
|
637
|
+
scripts?: Record<string, string>;
|
|
638
|
+
dependencies?: Record<string, string>;
|
|
639
|
+
devDependencies?: Record<string, string>;
|
|
640
|
+
engines?: Record<string, string>;
|
|
641
|
+
private?: boolean;
|
|
642
|
+
repository?: unknown;
|
|
643
|
+
} | null = null;
|
|
644
|
+
try {
|
|
645
|
+
parsed = JSON.parse(pkg.content);
|
|
646
|
+
} catch {
|
|
647
|
+
addCheck(
|
|
648
|
+
ctx,
|
|
649
|
+
"package-json",
|
|
650
|
+
"package",
|
|
651
|
+
"package.json present",
|
|
652
|
+
"fail",
|
|
653
|
+
"The package.json file could not be parsed. Many scanners and tools will fail on it.",
|
|
654
|
+
{ file: "package.json" },
|
|
655
|
+
);
|
|
656
|
+
ctx.findings.push(
|
|
657
|
+
makeFinding(
|
|
658
|
+
"package-json-invalid",
|
|
659
|
+
"package.json is not valid JSON",
|
|
660
|
+
"The package.json file could not be parsed. Many scanners and tools will fail on it.",
|
|
661
|
+
"high",
|
|
662
|
+
"package",
|
|
663
|
+
"Fix the JSON syntax in package.json so it parses cleanly.",
|
|
664
|
+
{ file: "package.json" },
|
|
665
|
+
),
|
|
666
|
+
);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
addCheck(
|
|
671
|
+
ctx,
|
|
672
|
+
"package-json",
|
|
673
|
+
"package",
|
|
674
|
+
"package.json present",
|
|
675
|
+
"pass",
|
|
676
|
+
"package.json parses cleanly.",
|
|
677
|
+
{ file: "package.json" },
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
const scripts = parsed?.scripts ?? {};
|
|
681
|
+
const required: Array<{
|
|
682
|
+
name: string;
|
|
683
|
+
id: string;
|
|
684
|
+
severity: Severity;
|
|
685
|
+
fix: string;
|
|
686
|
+
}> = [
|
|
687
|
+
{
|
|
688
|
+
name: "test",
|
|
689
|
+
id: "pkg-test",
|
|
690
|
+
severity: "low",
|
|
691
|
+
fix: "Add a `test` script so contributors and CI can run your test suite.",
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
name: "lint",
|
|
695
|
+
id: "pkg-lint",
|
|
696
|
+
severity: "low",
|
|
697
|
+
fix: "Add a `lint` script (eslint, biome, etc.) to catch issues before they ship.",
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
name: "audit",
|
|
701
|
+
id: "pkg-audit",
|
|
702
|
+
severity: "low",
|
|
703
|
+
fix: "Add an `audit` script that runs `npm audit --omit=dev` (or equivalent) for dependency safety.",
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
name: "start",
|
|
707
|
+
id: "pkg-start",
|
|
708
|
+
severity: "info",
|
|
709
|
+
fix: "Add a `start` script so users can run the app in production mode.",
|
|
710
|
+
},
|
|
711
|
+
];
|
|
712
|
+
|
|
713
|
+
for (const req of required) {
|
|
714
|
+
if (!scripts[req.name]) {
|
|
715
|
+
addCheck(
|
|
716
|
+
ctx,
|
|
717
|
+
req.id,
|
|
718
|
+
"package",
|
|
719
|
+
`package.json has a "${req.name}" script`,
|
|
720
|
+
req.severity === "low" ? "warn" : "info",
|
|
721
|
+
`The package.json has no "${req.name}" script. CI and contributors rely on standard script names.`,
|
|
722
|
+
{ file: "package.json" },
|
|
723
|
+
);
|
|
724
|
+
ctx.findings.push(
|
|
725
|
+
makeFinding(
|
|
726
|
+
req.id,
|
|
727
|
+
`Missing package.json script: ${req.name}`,
|
|
728
|
+
`The package.json has no "${req.name}" script. CI and contributors rely on standard script names.`,
|
|
729
|
+
req.severity,
|
|
730
|
+
"package",
|
|
731
|
+
req.fix,
|
|
732
|
+
{ file: "package.json" },
|
|
733
|
+
),
|
|
734
|
+
);
|
|
735
|
+
} else {
|
|
736
|
+
addCheck(
|
|
737
|
+
ctx,
|
|
738
|
+
req.id,
|
|
739
|
+
"package",
|
|
740
|
+
`package.json has a "${req.name}" script`,
|
|
741
|
+
"pass",
|
|
742
|
+
`Script "${req.name}" is defined.`,
|
|
743
|
+
{ file: "package.json" },
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function checkGithub(ctx: ScanContext): void {
|
|
750
|
+
if (!ctx.repo.hasWorkflows) {
|
|
751
|
+
missingFile(ctx, ".github/workflows");
|
|
752
|
+
addCheck(
|
|
753
|
+
ctx,
|
|
754
|
+
"gh-workflows",
|
|
755
|
+
"github",
|
|
756
|
+
"GitHub Actions workflows present",
|
|
757
|
+
"warn",
|
|
758
|
+
"There are no files under .github/workflows. CI is either missing or not committed.",
|
|
759
|
+
{ file: ".github/workflows" },
|
|
760
|
+
);
|
|
761
|
+
ctx.findings.push(
|
|
762
|
+
makeFinding(
|
|
763
|
+
"gh-no-workflows",
|
|
764
|
+
"No GitHub Actions workflows",
|
|
765
|
+
"There are no files under .github/workflows. CI is either missing or not committed.",
|
|
766
|
+
"low",
|
|
767
|
+
"github",
|
|
768
|
+
"Add at least a basic CI workflow that installs dependencies, runs lint and tests, and runs an audit step.",
|
|
769
|
+
),
|
|
770
|
+
);
|
|
771
|
+
} else {
|
|
772
|
+
presentFile(ctx, ".github/workflows");
|
|
773
|
+
addCheck(
|
|
774
|
+
ctx,
|
|
775
|
+
"gh-workflows",
|
|
776
|
+
"github",
|
|
777
|
+
"GitHub Actions workflows present",
|
|
778
|
+
"pass",
|
|
779
|
+
`${ctx.repo.workflows.length} workflow file(s) found.`,
|
|
780
|
+
{ file: ".github/workflows" },
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (!ctx.repo.hasDependabot) {
|
|
785
|
+
missingFile(ctx, ".github/dependabot.yml");
|
|
786
|
+
addCheck(
|
|
787
|
+
ctx,
|
|
788
|
+
"gh-dependabot",
|
|
789
|
+
"github",
|
|
790
|
+
"Dependabot configured",
|
|
791
|
+
"fail",
|
|
792
|
+
"There is no .github/dependabot.yml. Vulnerable dependencies will not get automatic pull requests.",
|
|
793
|
+
{ file: ".github/dependabot.yml" },
|
|
794
|
+
);
|
|
795
|
+
ctx.findings.push(
|
|
796
|
+
makeFinding(
|
|
797
|
+
"gh-no-dependabot",
|
|
798
|
+
"Dependabot is not configured",
|
|
799
|
+
"There is no .github/dependabot.yml. Vulnerable dependencies will not get automatic pull requests.",
|
|
800
|
+
"medium",
|
|
801
|
+
"github",
|
|
802
|
+
"Add a .github/dependabot.yml with at least one ecosystem (npm, pip, etc.) and a sensible schedule.",
|
|
803
|
+
{
|
|
804
|
+
fixPrompt:
|
|
805
|
+
"Add a .github/dependabot.yml that enables Dependabot for the package ecosystems used in this repo.",
|
|
806
|
+
},
|
|
807
|
+
),
|
|
808
|
+
);
|
|
809
|
+
} else {
|
|
810
|
+
presentFile(ctx, ".github/dependabot.yml");
|
|
811
|
+
addCheck(
|
|
812
|
+
ctx,
|
|
813
|
+
"gh-dependabot",
|
|
814
|
+
"github",
|
|
815
|
+
"Dependabot configured",
|
|
816
|
+
"pass",
|
|
817
|
+
"Dependabot configuration is present.",
|
|
818
|
+
{ file: ".github/dependabot.yml" },
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function checkDockerfile(ctx: ScanContext): void {
|
|
824
|
+
if (!ctx.repo.hasDockerfile) {
|
|
825
|
+
missingFile(ctx, "Dockerfile");
|
|
826
|
+
addCheck(
|
|
827
|
+
ctx,
|
|
828
|
+
"dockerfile",
|
|
829
|
+
"docker",
|
|
830
|
+
"Dockerfile present",
|
|
831
|
+
"info",
|
|
832
|
+
"No Dockerfile is present. Container hygiene checks were skipped.",
|
|
833
|
+
{ file: "Dockerfile" },
|
|
834
|
+
);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const dockerfile = findFile(
|
|
838
|
+
ctx.repo.files,
|
|
839
|
+
"Dockerfile",
|
|
840
|
+
);
|
|
841
|
+
if (!dockerfile) {
|
|
842
|
+
const candidates = ctx.repo.files.filter((f) =>
|
|
843
|
+
/^Dockerfile(\.[^/]+)?$/.test(f.path),
|
|
844
|
+
);
|
|
845
|
+
if (candidates.length === 0) return;
|
|
846
|
+
candidates.forEach((c) => presentFile(ctx, c.path));
|
|
847
|
+
return runDockerfileChecks(ctx, candidates[0]);
|
|
848
|
+
}
|
|
849
|
+
presentFile(ctx, "Dockerfile");
|
|
850
|
+
runDockerfileChecks(ctx, dockerfile);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function runDockerfileChecks(ctx: ScanContext, dockerfile: RepoFile): void {
|
|
854
|
+
const lines = getLines(dockerfile.content);
|
|
855
|
+
const content = dockerfile.content;
|
|
856
|
+
|
|
857
|
+
const hasUser = /(?:^|\n)\s*USER\s+\S+/i.test(content);
|
|
858
|
+
if (!hasUser) {
|
|
859
|
+
addCheck(
|
|
860
|
+
ctx,
|
|
861
|
+
"docker-user",
|
|
862
|
+
"docker",
|
|
863
|
+
"Dockerfile sets a non-root USER",
|
|
864
|
+
"warn",
|
|
865
|
+
"No USER directive was found. The container will run as root by default.",
|
|
866
|
+
{ file: dockerfile.path },
|
|
867
|
+
);
|
|
868
|
+
ctx.findings.push(
|
|
869
|
+
makeFinding(
|
|
870
|
+
"docker-user-missing",
|
|
871
|
+
"Dockerfile is missing a USER directive",
|
|
872
|
+
"No USER directive was found in the Dockerfile. The container will run as root by default, which violates least-privilege.",
|
|
873
|
+
"medium",
|
|
874
|
+
"docker",
|
|
875
|
+
"Add a non-root user with `RUN adduser` and switch to it via `USER <name>` before the CMD/ENTRYPOINT.",
|
|
876
|
+
{ file: dockerfile.path },
|
|
877
|
+
),
|
|
878
|
+
);
|
|
879
|
+
} else {
|
|
880
|
+
addCheck(
|
|
881
|
+
ctx,
|
|
882
|
+
"docker-user",
|
|
883
|
+
"docker",
|
|
884
|
+
"Dockerfile sets a non-root USER",
|
|
885
|
+
"pass",
|
|
886
|
+
"USER directive is set.",
|
|
887
|
+
{ file: dockerfile.path },
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const hasHealthcheck = /^\s*HEALTHCHECK\b/im.test(content);
|
|
892
|
+
if (!hasHealthcheck) {
|
|
893
|
+
addCheck(
|
|
894
|
+
ctx,
|
|
895
|
+
"docker-healthcheck",
|
|
896
|
+
"docker",
|
|
897
|
+
"Dockerfile defines a HEALTHCHECK",
|
|
898
|
+
"info",
|
|
899
|
+
"No HEALTHCHECK was found. Orchestrators cannot detect a dead container.",
|
|
900
|
+
{ file: dockerfile.path },
|
|
901
|
+
);
|
|
902
|
+
} else {
|
|
903
|
+
addCheck(
|
|
904
|
+
ctx,
|
|
905
|
+
"docker-healthcheck",
|
|
906
|
+
"docker",
|
|
907
|
+
"Dockerfile defines a HEALTHCHECK",
|
|
908
|
+
"pass",
|
|
909
|
+
"HEALTHCHECK directive is set.",
|
|
910
|
+
{ file: dockerfile.path },
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const fromRegex = /^\s*FROM\s+([^\s]+).*$/gim;
|
|
915
|
+
let m: RegExpExecArray | null;
|
|
916
|
+
let latestUsed = false;
|
|
917
|
+
while ((m = fromRegex.exec(content)) !== null) {
|
|
918
|
+
const ref = m[1];
|
|
919
|
+
if (!ref) continue;
|
|
920
|
+
if (ref.endsWith(":latest") || !ref.includes(":")) latestUsed = true;
|
|
921
|
+
}
|
|
922
|
+
if (latestUsed) {
|
|
923
|
+
addCheck(
|
|
924
|
+
ctx,
|
|
925
|
+
"docker-tag",
|
|
926
|
+
"docker",
|
|
927
|
+
"Dockerfile pins image versions",
|
|
928
|
+
"warn",
|
|
929
|
+
"At least one FROM line uses :latest or has no tag. Builds will not be reproducible.",
|
|
930
|
+
{ file: dockerfile.path },
|
|
931
|
+
);
|
|
932
|
+
ctx.findings.push(
|
|
933
|
+
makeFinding(
|
|
934
|
+
"docker-tag-latest",
|
|
935
|
+
"Dockerfile uses :latest or untagged base image",
|
|
936
|
+
"At least one FROM line uses :latest or has no tag. Builds will not be reproducible and security patches will not be deterministic.",
|
|
937
|
+
"low",
|
|
938
|
+
"docker",
|
|
939
|
+
"Pin every FROM to a specific digest or version (e.g. `FROM node:20.11-alpine@sha256:...`).",
|
|
940
|
+
{ file: dockerfile.path },
|
|
941
|
+
),
|
|
942
|
+
);
|
|
943
|
+
} else {
|
|
944
|
+
addCheck(
|
|
945
|
+
ctx,
|
|
946
|
+
"docker-tag",
|
|
947
|
+
"docker",
|
|
948
|
+
"Dockerfile pins image versions",
|
|
949
|
+
"pass",
|
|
950
|
+
"All FROM references include a tag or digest.",
|
|
951
|
+
{ file: dockerfile.path },
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const hasAdd = /^\s*ADD\s+https?:\/\//im.test(content);
|
|
956
|
+
if (hasAdd) {
|
|
957
|
+
addCheck(
|
|
958
|
+
ctx,
|
|
959
|
+
"docker-add",
|
|
960
|
+
"docker",
|
|
961
|
+
"Dockerfile avoids ADD with URLs",
|
|
962
|
+
"info",
|
|
963
|
+
"ADD with a URL was detected. Prefer RUN curl/wget with a checksum.",
|
|
964
|
+
{ file: dockerfile.path },
|
|
965
|
+
);
|
|
966
|
+
ctx.findings.push(
|
|
967
|
+
makeFinding(
|
|
968
|
+
"docker-add-url",
|
|
969
|
+
"Dockerfile uses ADD with a remote URL",
|
|
970
|
+
"ADD with a remote URL bypasses the layer cache and skips checksum verification. Use RUN curl/wget with a verified checksum instead.",
|
|
971
|
+
"low",
|
|
972
|
+
"docker",
|
|
973
|
+
"Replace `ADD <url>` with `RUN curl -fsSL <url> | sha256sum -c -` or a pinned download step.",
|
|
974
|
+
{ file: dockerfile.path },
|
|
975
|
+
),
|
|
976
|
+
);
|
|
977
|
+
} else {
|
|
978
|
+
addCheck(
|
|
979
|
+
ctx,
|
|
980
|
+
"docker-add",
|
|
981
|
+
"docker",
|
|
982
|
+
"Dockerfile avoids ADD with URLs",
|
|
983
|
+
"pass",
|
|
984
|
+
"No ADD with remote URLs.",
|
|
985
|
+
{ file: dockerfile.path },
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const hasSshExpose = /^\s*EXPOSE\s+(?:.*\s)?22\b/im.test(content);
|
|
990
|
+
if (hasSshExpose) {
|
|
991
|
+
addCheck(
|
|
992
|
+
ctx,
|
|
993
|
+
"docker-ssh",
|
|
994
|
+
"docker",
|
|
995
|
+
"Dockerfile does not expose SSH",
|
|
996
|
+
"fail",
|
|
997
|
+
"Port 22 is exposed in the Dockerfile. Containers should not run an SSH server.",
|
|
998
|
+
{ file: dockerfile.path },
|
|
999
|
+
);
|
|
1000
|
+
ctx.findings.push(
|
|
1001
|
+
makeFinding(
|
|
1002
|
+
"docker-ssh-expose",
|
|
1003
|
+
"Dockerfile exposes SSH port 22",
|
|
1004
|
+
"EXPOSE 22 was found. Running an SSH server inside a container is almost never required and broadens the attack surface.",
|
|
1005
|
+
"high",
|
|
1006
|
+
"docker",
|
|
1007
|
+
"Remove the SSH server and EXPOSE 22. Use `docker exec` or orchestrator-level access instead.",
|
|
1008
|
+
{ file: dockerfile.path },
|
|
1009
|
+
),
|
|
1010
|
+
);
|
|
1011
|
+
} else {
|
|
1012
|
+
addCheck(
|
|
1013
|
+
ctx,
|
|
1014
|
+
"docker-ssh",
|
|
1015
|
+
"docker",
|
|
1016
|
+
"Dockerfile does not expose SSH",
|
|
1017
|
+
"pass",
|
|
1018
|
+
"No SSH port exposure detected.",
|
|
1019
|
+
{ file: dockerfile.path },
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
void lines;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function checkCommunity(ctx: ScanContext): void {
|
|
1027
|
+
if (!ctx.repo.hasIssueTemplate) {
|
|
1028
|
+
missingFile(ctx, ".github/ISSUE_TEMPLATE/");
|
|
1029
|
+
addCheck(
|
|
1030
|
+
ctx,
|
|
1031
|
+
"issue-template",
|
|
1032
|
+
"community",
|
|
1033
|
+
"Issue templates present",
|
|
1034
|
+
"info",
|
|
1035
|
+
"No files under .github/ISSUE_TEMPLATE/. New issues may lack structure.",
|
|
1036
|
+
{ file: ".github/ISSUE_TEMPLATE/" },
|
|
1037
|
+
);
|
|
1038
|
+
} else {
|
|
1039
|
+
presentFile(ctx, ".github/ISSUE_TEMPLATE/");
|
|
1040
|
+
addCheck(
|
|
1041
|
+
ctx,
|
|
1042
|
+
"issue-template",
|
|
1043
|
+
"community",
|
|
1044
|
+
"Issue templates present",
|
|
1045
|
+
"pass",
|
|
1046
|
+
"Issue template directory is present.",
|
|
1047
|
+
{ file: ".github/ISSUE_TEMPLATE/" },
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (!ctx.repo.hasPullRequestTemplate) {
|
|
1052
|
+
missingFile(ctx, "PULL_REQUEST_TEMPLATE.md");
|
|
1053
|
+
addCheck(
|
|
1054
|
+
ctx,
|
|
1055
|
+
"pr-template",
|
|
1056
|
+
"community",
|
|
1057
|
+
"Pull request template present",
|
|
1058
|
+
"info",
|
|
1059
|
+
"No PULL_REQUEST_TEMPLATE.md. New PRs may lack context.",
|
|
1060
|
+
{ file: "PULL_REQUEST_TEMPLATE.md" },
|
|
1061
|
+
);
|
|
1062
|
+
} else {
|
|
1063
|
+
presentFile(ctx, "PULL_REQUEST_TEMPLATE.md");
|
|
1064
|
+
addCheck(
|
|
1065
|
+
ctx,
|
|
1066
|
+
"pr-template",
|
|
1067
|
+
"community",
|
|
1068
|
+
"Pull request template present",
|
|
1069
|
+
"pass",
|
|
1070
|
+
"Pull request template is present.",
|
|
1071
|
+
{ file: "PULL_REQUEST_TEMPLATE.md" },
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (!ctx.repo.hasCodeowners) {
|
|
1076
|
+
missingFile(ctx, ".github/CODEOWNERS");
|
|
1077
|
+
addCheck(
|
|
1078
|
+
ctx,
|
|
1079
|
+
"codeowners",
|
|
1080
|
+
"community",
|
|
1081
|
+
"CODEOWNERS defined",
|
|
1082
|
+
"warn",
|
|
1083
|
+
"No CODEOWNERS file was found. Reviewers are not auto-assigned.",
|
|
1084
|
+
{ file: ".github/CODEOWNERS" },
|
|
1085
|
+
);
|
|
1086
|
+
ctx.findings.push(
|
|
1087
|
+
makeFinding(
|
|
1088
|
+
"codeowners-missing",
|
|
1089
|
+
"Missing CODEOWNERS",
|
|
1090
|
+
"No CODEOWNERS file was found. Pull requests will not be auto-assigned to the right reviewers.",
|
|
1091
|
+
"low",
|
|
1092
|
+
"community",
|
|
1093
|
+
"Add a CODEOWNERS file under .github/ or the repository root, listing the teams that own key paths.",
|
|
1094
|
+
),
|
|
1095
|
+
);
|
|
1096
|
+
} else {
|
|
1097
|
+
presentFile(ctx, ".github/CODEOWNERS");
|
|
1098
|
+
addCheck(
|
|
1099
|
+
ctx,
|
|
1100
|
+
"codeowners",
|
|
1101
|
+
"community",
|
|
1102
|
+
"CODEOWNERS defined",
|
|
1103
|
+
"pass",
|
|
1104
|
+
"CODEOWNERS is configured.",
|
|
1105
|
+
{ file: ".github/CODEOWNERS" },
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (!ctx.repo.hasCodeOfConduct) {
|
|
1110
|
+
missingFile(ctx, "CODE_OF_CONDUCT.md");
|
|
1111
|
+
addCheck(
|
|
1112
|
+
ctx,
|
|
1113
|
+
"code-of-conduct",
|
|
1114
|
+
"community",
|
|
1115
|
+
"Code of conduct present",
|
|
1116
|
+
"info",
|
|
1117
|
+
"No CODE_OF_CONDUCT.md. Contributor behavior expectations are not documented.",
|
|
1118
|
+
{ file: "CODE_OF_CONDUCT.md" },
|
|
1119
|
+
);
|
|
1120
|
+
} else {
|
|
1121
|
+
presentFile(ctx, "CODE_OF_CONDUCT.md");
|
|
1122
|
+
addCheck(
|
|
1123
|
+
ctx,
|
|
1124
|
+
"code-of-conduct",
|
|
1125
|
+
"community",
|
|
1126
|
+
"Code of conduct present",
|
|
1127
|
+
"pass",
|
|
1128
|
+
"Code of conduct is documented.",
|
|
1129
|
+
{ file: "CODE_OF_CONDUCT.md" },
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (!ctx.repo.hasContributing) {
|
|
1134
|
+
missingFile(ctx, "CONTRIBUTING.md");
|
|
1135
|
+
addCheck(
|
|
1136
|
+
ctx,
|
|
1137
|
+
"contributing",
|
|
1138
|
+
"community",
|
|
1139
|
+
"CONTRIBUTING.md present",
|
|
1140
|
+
"info",
|
|
1141
|
+
"No CONTRIBUTING.md. New contributors will not know how to submit changes.",
|
|
1142
|
+
{ file: "CONTRIBUTING.md" },
|
|
1143
|
+
);
|
|
1144
|
+
} else {
|
|
1145
|
+
presentFile(ctx, "CONTRIBUTING.md");
|
|
1146
|
+
addCheck(
|
|
1147
|
+
ctx,
|
|
1148
|
+
"contributing",
|
|
1149
|
+
"community",
|
|
1150
|
+
"CONTRIBUTING.md present",
|
|
1151
|
+
"pass",
|
|
1152
|
+
"CONTRIBUTING.md is present.",
|
|
1153
|
+
{ file: "CONTRIBUTING.md" },
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (!ctx.repo.hasChangelog) {
|
|
1158
|
+
missingFile(ctx, "CHANGELOG.md");
|
|
1159
|
+
addCheck(
|
|
1160
|
+
ctx,
|
|
1161
|
+
"changelog",
|
|
1162
|
+
"community",
|
|
1163
|
+
"Changelog or release notes present",
|
|
1164
|
+
"info",
|
|
1165
|
+
"No CHANGELOG.md or release notes. Users cannot see what changed between versions.",
|
|
1166
|
+
{ file: "CHANGELOG.md" },
|
|
1167
|
+
);
|
|
1168
|
+
} else {
|
|
1169
|
+
presentFile(ctx, "CHANGELOG.md");
|
|
1170
|
+
addCheck(
|
|
1171
|
+
ctx,
|
|
1172
|
+
"changelog",
|
|
1173
|
+
"community",
|
|
1174
|
+
"Changelog or release notes present",
|
|
1175
|
+
"pass",
|
|
1176
|
+
"Changelog or release notes are present.",
|
|
1177
|
+
{ file: "CHANGELOG.md" },
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function checkWorkflowQuality(ctx: ScanContext): void {
|
|
1183
|
+
const workflowFiles = ctx.repo.files.filter((f) =>
|
|
1184
|
+
f.path.startsWith(".github/workflows/"),
|
|
1185
|
+
);
|
|
1186
|
+
if (workflowFiles.length === 0) {
|
|
1187
|
+
addCheck(
|
|
1188
|
+
ctx,
|
|
1189
|
+
"ci-quality",
|
|
1190
|
+
"ci",
|
|
1191
|
+
"CI workflow quality",
|
|
1192
|
+
"info",
|
|
1193
|
+
"No workflows to inspect (see GitHub Actions check above).",
|
|
1194
|
+
);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
let anyOnPullRequest = false;
|
|
1199
|
+
let runsTests = false;
|
|
1200
|
+
let runsAudit = false;
|
|
1201
|
+
let hasWriteAll = false;
|
|
1202
|
+
const TEST_PATTERN =
|
|
1203
|
+
/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|\bcargo\s+test\b|\bgo\s+test\b|\bpytest\b|\bmake\s+test\b|\bgradle\s+test\b|\bmvn\s+test\b|\bdotnet\s+test\b|\bbun\s+test\b|\bvitest\s+run\b|\bjest\b/i;
|
|
1204
|
+
const AUDIT_PATTERN =
|
|
1205
|
+
/\bnpm\s+audit\b|\bpnpm\s+audit\b|\byarn\s+audit\b|\bpip-audit\b|\bsafety\s+(?:check|scan)\b|\bcargo\s+audit\b|\bdotnet\s+list\s+package\s+--vulnerable\b|\btrivy\b|\bsnyk\b|\bdependabot\b|\bgovulncheck\b|\bcomposer\s+audit\b/i;
|
|
1206
|
+
for (const wf of workflowFiles) {
|
|
1207
|
+
const lower = wf.content.toLowerCase();
|
|
1208
|
+
if (lower.includes("pull_request")) anyOnPullRequest = true;
|
|
1209
|
+
if (TEST_PATTERN.test(wf.content)) runsTests = true;
|
|
1210
|
+
if (AUDIT_PATTERN.test(wf.content)) runsAudit = true;
|
|
1211
|
+
if (/permissions:\s*write-all/.test(lower)) hasWriteAll = true;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (!anyOnPullRequest) {
|
|
1215
|
+
addCheck(
|
|
1216
|
+
ctx,
|
|
1217
|
+
"ci-pull-request",
|
|
1218
|
+
"ci",
|
|
1219
|
+
"At least one workflow runs on pull_request",
|
|
1220
|
+
"warn",
|
|
1221
|
+
"No workflow is triggered by pull_request events. Pull requests are not gated by CI.",
|
|
1222
|
+
{ file: ".github/workflows/" },
|
|
1223
|
+
);
|
|
1224
|
+
ctx.findings.push(
|
|
1225
|
+
makeFinding(
|
|
1226
|
+
"ci-no-pr-trigger",
|
|
1227
|
+
"No CI runs on pull_request",
|
|
1228
|
+
"No workflow triggers on pull_request. Open PRs may merge without running the test suite.",
|
|
1229
|
+
"medium",
|
|
1230
|
+
"ci",
|
|
1231
|
+
"Add `on: pull_request` to at least one workflow so every PR runs the test suite before merge.",
|
|
1232
|
+
{ file: ".github/workflows/" },
|
|
1233
|
+
),
|
|
1234
|
+
);
|
|
1235
|
+
} else {
|
|
1236
|
+
addCheck(
|
|
1237
|
+
ctx,
|
|
1238
|
+
"ci-pull-request",
|
|
1239
|
+
"ci",
|
|
1240
|
+
"At least one workflow runs on pull_request",
|
|
1241
|
+
"pass",
|
|
1242
|
+
"At least one workflow is triggered by pull_request.",
|
|
1243
|
+
{ file: ".github/workflows/" },
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (!runsTests) {
|
|
1248
|
+
addCheck(
|
|
1249
|
+
ctx,
|
|
1250
|
+
"ci-tests",
|
|
1251
|
+
"ci",
|
|
1252
|
+
"A workflow runs the test suite",
|
|
1253
|
+
"warn",
|
|
1254
|
+
"No workflow appears to run a test suite (npm/pnpm/yarn test).",
|
|
1255
|
+
{ file: ".github/workflows/" },
|
|
1256
|
+
);
|
|
1257
|
+
ctx.findings.push(
|
|
1258
|
+
makeFinding(
|
|
1259
|
+
"ci-no-tests",
|
|
1260
|
+
"CI does not run a test suite",
|
|
1261
|
+
"No workflow runs a test suite. Bugs may slip through to the default branch.",
|
|
1262
|
+
"medium",
|
|
1263
|
+
"ci",
|
|
1264
|
+
"Add a step that runs `npm test` (or the equivalent) to a workflow triggered on push and pull_request.",
|
|
1265
|
+
{ file: ".github/workflows/" },
|
|
1266
|
+
),
|
|
1267
|
+
);
|
|
1268
|
+
} else {
|
|
1269
|
+
addCheck(
|
|
1270
|
+
ctx,
|
|
1271
|
+
"ci-tests",
|
|
1272
|
+
"ci",
|
|
1273
|
+
"A workflow runs the test suite",
|
|
1274
|
+
"pass",
|
|
1275
|
+
"A test step is present in the workflows.",
|
|
1276
|
+
{ file: ".github/workflows/" },
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (!runsAudit) {
|
|
1281
|
+
addCheck(
|
|
1282
|
+
ctx,
|
|
1283
|
+
"ci-audit",
|
|
1284
|
+
"ci",
|
|
1285
|
+
"A workflow runs dependency audit",
|
|
1286
|
+
"info",
|
|
1287
|
+
"No workflow runs a dependency audit (npm audit, pip-audit, etc.).",
|
|
1288
|
+
{ file: ".github/workflows/" },
|
|
1289
|
+
);
|
|
1290
|
+
} else {
|
|
1291
|
+
addCheck(
|
|
1292
|
+
ctx,
|
|
1293
|
+
"ci-audit",
|
|
1294
|
+
"ci",
|
|
1295
|
+
"A workflow runs dependency audit",
|
|
1296
|
+
"pass",
|
|
1297
|
+
"A dependency audit step is present.",
|
|
1298
|
+
{ file: ".github/workflows/" },
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
if (hasWriteAll) {
|
|
1303
|
+
addCheck(
|
|
1304
|
+
ctx,
|
|
1305
|
+
"ci-permissions",
|
|
1306
|
+
"ci",
|
|
1307
|
+
"Workflow permissions are scoped",
|
|
1308
|
+
"fail",
|
|
1309
|
+
"At least one workflow uses `permissions: write-all`. This grants every step write access by default.",
|
|
1310
|
+
{ file: ".github/workflows/" },
|
|
1311
|
+
);
|
|
1312
|
+
ctx.findings.push(
|
|
1313
|
+
makeFinding(
|
|
1314
|
+
"ci-permissions-write-all",
|
|
1315
|
+
"Workflow uses permissions: write-all",
|
|
1316
|
+
"At least one workflow uses `permissions: write-all`. Every step can read AND write to the repository by default; prefer the least-privilege default of `permissions: read-all` or a scoped permissions block.",
|
|
1317
|
+
"high",
|
|
1318
|
+
"ci",
|
|
1319
|
+
"Replace `permissions: write-all` with `permissions: read-all` at the workflow level, then escalate per-job with `permissions:` as needed.",
|
|
1320
|
+
{ file: ".github/workflows/" },
|
|
1321
|
+
),
|
|
1322
|
+
);
|
|
1323
|
+
} else {
|
|
1324
|
+
addCheck(
|
|
1325
|
+
ctx,
|
|
1326
|
+
"ci-permissions",
|
|
1327
|
+
"ci",
|
|
1328
|
+
"Workflow permissions are scoped",
|
|
1329
|
+
"pass",
|
|
1330
|
+
"No `permissions: write-all` was detected.",
|
|
1331
|
+
{ file: ".github/workflows/" },
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function checkMetadata(ctx: ScanContext): void {
|
|
1337
|
+
const meta = ctx.repo.metadata;
|
|
1338
|
+
|
|
1339
|
+
if (!meta.description || meta.description.trim().length === 0) {
|
|
1340
|
+
addCheck(
|
|
1341
|
+
ctx,
|
|
1342
|
+
"meta-description",
|
|
1343
|
+
"metadata",
|
|
1344
|
+
"Repository has a description",
|
|
1345
|
+
"info",
|
|
1346
|
+
"No repository description is set on GitHub.",
|
|
1347
|
+
);
|
|
1348
|
+
ctx.findings.push(
|
|
1349
|
+
makeFinding(
|
|
1350
|
+
"meta-no-description",
|
|
1351
|
+
"Repository description is empty",
|
|
1352
|
+
"No description is set on GitHub. Search results and the repo header will look incomplete.",
|
|
1353
|
+
"info",
|
|
1354
|
+
"metadata",
|
|
1355
|
+
"Open Settings and add a one-line description of what the repository does.",
|
|
1356
|
+
),
|
|
1357
|
+
);
|
|
1358
|
+
} else {
|
|
1359
|
+
addCheck(
|
|
1360
|
+
ctx,
|
|
1361
|
+
"meta-description",
|
|
1362
|
+
"metadata",
|
|
1363
|
+
"Repository has a description",
|
|
1364
|
+
"pass",
|
|
1365
|
+
`Description: "${truncateDescription(meta.description)}"`,
|
|
1366
|
+
);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
if (!meta.topics || meta.topics.length === 0) {
|
|
1370
|
+
addCheck(
|
|
1371
|
+
ctx,
|
|
1372
|
+
"meta-topics",
|
|
1373
|
+
"metadata",
|
|
1374
|
+
"Repository topics are set",
|
|
1375
|
+
"info",
|
|
1376
|
+
"No topics are set. The repo is harder to find via GitHub search.",
|
|
1377
|
+
);
|
|
1378
|
+
} else {
|
|
1379
|
+
addCheck(
|
|
1380
|
+
ctx,
|
|
1381
|
+
"meta-topics",
|
|
1382
|
+
"metadata",
|
|
1383
|
+
"Repository topics are set",
|
|
1384
|
+
"pass",
|
|
1385
|
+
`${meta.topics.length} topic(s): ${meta.topics.slice(0, 5).join(", ")}${meta.topics.length > 5 ? "\u2026" : ""}`,
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
if (meta.archived) {
|
|
1390
|
+
addCheck(
|
|
1391
|
+
ctx,
|
|
1392
|
+
"meta-archived",
|
|
1393
|
+
"metadata",
|
|
1394
|
+
"Repository is not archived",
|
|
1395
|
+
"info",
|
|
1396
|
+
"The repository is marked as archived. Issues and PRs are disabled on GitHub.",
|
|
1397
|
+
);
|
|
1398
|
+
ctx.findings.push(
|
|
1399
|
+
makeFinding(
|
|
1400
|
+
"meta-archived",
|
|
1401
|
+
"Repository is archived",
|
|
1402
|
+
"This repository is archived. No new issues or PRs can be opened on GitHub.",
|
|
1403
|
+
"info",
|
|
1404
|
+
"metadata",
|
|
1405
|
+
"Unarchive the repository (Settings -> General -> Archive) if it is still being maintained, otherwise note this in the README.",
|
|
1406
|
+
),
|
|
1407
|
+
);
|
|
1408
|
+
} else {
|
|
1409
|
+
addCheck(
|
|
1410
|
+
ctx,
|
|
1411
|
+
"meta-archived",
|
|
1412
|
+
"metadata",
|
|
1413
|
+
"Repository is not archived",
|
|
1414
|
+
"pass",
|
|
1415
|
+
"Repository is active.",
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if (meta.defaultBranch && /^master$/i.test(meta.defaultBranch)) {
|
|
1420
|
+
addCheck(
|
|
1421
|
+
ctx,
|
|
1422
|
+
"meta-branch",
|
|
1423
|
+
"metadata",
|
|
1424
|
+
"Default branch is not 'master'",
|
|
1425
|
+
"info",
|
|
1426
|
+
"The default branch is 'master'. Many ecosystems and tools now default to 'main'.",
|
|
1427
|
+
);
|
|
1428
|
+
ctx.findings.push(
|
|
1429
|
+
makeFinding(
|
|
1430
|
+
"meta-master-branch",
|
|
1431
|
+
"Default branch is 'master'",
|
|
1432
|
+
"The default branch is 'master'. Renaming it to 'main' matches current ecosystem conventions.",
|
|
1433
|
+
"info",
|
|
1434
|
+
"metadata",
|
|
1435
|
+
"Use `git branch -m master main` and update the default branch in GitHub repository settings.",
|
|
1436
|
+
),
|
|
1437
|
+
);
|
|
1438
|
+
} else {
|
|
1439
|
+
addCheck(
|
|
1440
|
+
ctx,
|
|
1441
|
+
"meta-branch",
|
|
1442
|
+
"metadata",
|
|
1443
|
+
"Default branch is not 'master'",
|
|
1444
|
+
"pass",
|
|
1445
|
+
`Default branch is '${meta.defaultBranch}'.`,
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (!meta.licenseSpdxId) {
|
|
1450
|
+
addCheck(
|
|
1451
|
+
ctx,
|
|
1452
|
+
"meta-license",
|
|
1453
|
+
"metadata",
|
|
1454
|
+
"License detected on GitHub",
|
|
1455
|
+
"warn",
|
|
1456
|
+
"GitHub did not detect a license for this repository. The LICENSE file may be missing or non-standard.",
|
|
1457
|
+
);
|
|
1458
|
+
} else {
|
|
1459
|
+
addCheck(
|
|
1460
|
+
ctx,
|
|
1461
|
+
"meta-license",
|
|
1462
|
+
"metadata",
|
|
1463
|
+
"License detected on GitHub",
|
|
1464
|
+
"pass",
|
|
1465
|
+
`License: ${meta.licenseSpdxId}${meta.licenseName ? ` (${meta.licenseName})` : ""}`,
|
|
1466
|
+
);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function truncateDescription(s: string, max = 80): string {
|
|
1471
|
+
return s.length <= max ? s : s.slice(0, max - 1) + "\u2026";
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function checkCodePatterns(ctx: ScanContext): void {
|
|
1475
|
+
const TARGET_EXT = new Set([
|
|
1476
|
+
".ts",
|
|
1477
|
+
".tsx",
|
|
1478
|
+
".js",
|
|
1479
|
+
".jsx",
|
|
1480
|
+
".mjs",
|
|
1481
|
+
".cjs",
|
|
1482
|
+
]);
|
|
1483
|
+
const SKIP_DIRS = ["node_modules", "dist", "build", ".next", "out", "coverage", ".git", "vendor"];
|
|
1484
|
+
|
|
1485
|
+
for (const file of ctx.repo.files) {
|
|
1486
|
+
const lower = file.path.toLowerCase();
|
|
1487
|
+
if (SKIP_DIRS.some((d) => lower.includes(`/${d}/`) || lower.startsWith(`${d}/`))) {
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
if (isTestFile(lower)) continue;
|
|
1491
|
+
if (![...TARGET_EXT].some((ext) => lower.endsWith(ext))) continue;
|
|
1492
|
+
if (file.content.length > 1_500_000) continue;
|
|
1493
|
+
|
|
1494
|
+
const lines = getLines(file.content);
|
|
1495
|
+
const ext = lower.match(/\.([a-z0-9]+)$/)?.[1] ?? "default";
|
|
1496
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1497
|
+
const line = lines[i] ?? "";
|
|
1498
|
+
if (isCommentedLine(line, ext)) continue;
|
|
1499
|
+
|
|
1500
|
+
const evalMatch = /\beval\s*\(/.exec(line);
|
|
1501
|
+
if (evalMatch) {
|
|
1502
|
+
if (!isInsideString(line, evalMatch.index)) {
|
|
1503
|
+
addFindingAndCheck(
|
|
1504
|
+
ctx,
|
|
1505
|
+
{
|
|
1506
|
+
id: `code-eval-${file.path}-${i + 1}`,
|
|
1507
|
+
title: "Use of eval()",
|
|
1508
|
+
description:
|
|
1509
|
+
"eval() executes arbitrary strings as code and is almost always a remote code execution risk.",
|
|
1510
|
+
severity: "high",
|
|
1511
|
+
category: "code",
|
|
1512
|
+
fix: "Replace eval() with a safer alternative (JSON.parse for data, explicit dispatch for code paths).",
|
|
1513
|
+
file: file.path,
|
|
1514
|
+
line: i + 1,
|
|
1515
|
+
evidence: maskLine(line).slice(0, 200),
|
|
1516
|
+
},
|
|
1517
|
+
{
|
|
1518
|
+
id: "code-eval",
|
|
1519
|
+
category: "code",
|
|
1520
|
+
title: "No use of eval() in source",
|
|
1521
|
+
file: file.path,
|
|
1522
|
+
line: i + 1,
|
|
1523
|
+
},
|
|
1524
|
+
);
|
|
1525
|
+
break;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const fnMatch = /\bnew\s+Function\s*\(/.exec(line);
|
|
1530
|
+
if (fnMatch) {
|
|
1531
|
+
if (!isInsideString(line, fnMatch.index)) {
|
|
1532
|
+
addFindingAndCheck(
|
|
1533
|
+
ctx,
|
|
1534
|
+
{
|
|
1535
|
+
id: `code-new-function-${file.path}-${i + 1}`,
|
|
1536
|
+
title: "Use of new Function()",
|
|
1537
|
+
description:
|
|
1538
|
+
"new Function() evaluates a string at runtime, with the same risks as eval().",
|
|
1539
|
+
severity: "high",
|
|
1540
|
+
category: "code",
|
|
1541
|
+
fix: "Replace with a static function or a safe evaluator with a strict grammar.",
|
|
1542
|
+
file: file.path,
|
|
1543
|
+
line: i + 1,
|
|
1544
|
+
evidence: maskLine(line).slice(0, 200),
|
|
1545
|
+
},
|
|
1546
|
+
{
|
|
1547
|
+
id: "code-new-function",
|
|
1548
|
+
category: "code",
|
|
1549
|
+
title: "No use of new Function() in source",
|
|
1550
|
+
file: file.path,
|
|
1551
|
+
line: i + 1,
|
|
1552
|
+
},
|
|
1553
|
+
);
|
|
1554
|
+
break;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const dsrMatch = /dangerouslySetInnerHTML/.exec(line);
|
|
1559
|
+
if (dsrMatch) {
|
|
1560
|
+
if (!isInsideString(line, dsrMatch.index)) {
|
|
1561
|
+
addFindingAndCheck(
|
|
1562
|
+
ctx,
|
|
1563
|
+
{
|
|
1564
|
+
id: `code-dangerously-set-${file.path}-${i + 1}`,
|
|
1565
|
+
title: "Use of dangerouslySetInnerHTML",
|
|
1566
|
+
description:
|
|
1567
|
+
"dangerouslySetInnerHTML injects raw HTML into the DOM and bypasses React's XSS protections. Make sure the input is sanitized.",
|
|
1568
|
+
severity: "medium",
|
|
1569
|
+
category: "code",
|
|
1570
|
+
fix: "Sanitize input with DOMPurify (or equivalent) before passing it to dangerouslySetInnerHTML, or render via React children instead.",
|
|
1571
|
+
file: file.path,
|
|
1572
|
+
line: i + 1,
|
|
1573
|
+
evidence: maskLine(line).slice(0, 200),
|
|
1574
|
+
},
|
|
1575
|
+
{
|
|
1576
|
+
id: "code-dangerously-set",
|
|
1577
|
+
category: "code",
|
|
1578
|
+
title: "dangerouslySetInnerHTML is sanitized",
|
|
1579
|
+
file: file.path,
|
|
1580
|
+
line: i + 1,
|
|
1581
|
+
},
|
|
1582
|
+
);
|
|
1583
|
+
break;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function addFindingAndCheck(
|
|
1591
|
+
ctx: ScanContext,
|
|
1592
|
+
finding: Finding,
|
|
1593
|
+
check: { id: string; category: FindingCategory; title: string; file: string; line: number },
|
|
1594
|
+
): void {
|
|
1595
|
+
ctx.findings.push(finding);
|
|
1596
|
+
if (!ctx.checks.find((c) => c.id === check.id)) {
|
|
1597
|
+
addCheck(
|
|
1598
|
+
ctx,
|
|
1599
|
+
check.id,
|
|
1600
|
+
check.category,
|
|
1601
|
+
check.title,
|
|
1602
|
+
"fail",
|
|
1603
|
+
finding.description,
|
|
1604
|
+
{ file: check.file, line: check.line },
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function checkDependencies(ctx: ScanContext): void {
|
|
1610
|
+
const pkg = findFile(ctx.repo.files, "package.json");
|
|
1611
|
+
if (pkg) {
|
|
1612
|
+
let parsed:
|
|
1613
|
+
| {
|
|
1614
|
+
engines?: Record<string, string>;
|
|
1615
|
+
private?: boolean;
|
|
1616
|
+
repository?: unknown;
|
|
1617
|
+
name?: string;
|
|
1618
|
+
version?: string;
|
|
1619
|
+
}
|
|
1620
|
+
| null = null;
|
|
1621
|
+
try {
|
|
1622
|
+
parsed = JSON.parse(pkg.content);
|
|
1623
|
+
} catch {
|
|
1624
|
+
parsed = null;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
if (parsed) {
|
|
1628
|
+
if (!parsed.engines || Object.keys(parsed.engines).length === 0) {
|
|
1629
|
+
addCheck(
|
|
1630
|
+
ctx,
|
|
1631
|
+
"dep-engines",
|
|
1632
|
+
"dependencies",
|
|
1633
|
+
"package.json declares an engines field",
|
|
1634
|
+
"info",
|
|
1635
|
+
"No `engines` field. Consumers cannot tell which Node version the package supports.",
|
|
1636
|
+
{ file: "package.json" },
|
|
1637
|
+
);
|
|
1638
|
+
} else {
|
|
1639
|
+
addCheck(
|
|
1640
|
+
ctx,
|
|
1641
|
+
"dep-engines",
|
|
1642
|
+
"dependencies",
|
|
1643
|
+
"package.json declares an engines field",
|
|
1644
|
+
"pass",
|
|
1645
|
+
`Engines: ${Object.entries(parsed.engines).map(([k, v]) => `${k}=${v}`).join(", ")}`,
|
|
1646
|
+
{ file: "package.json" },
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
if (!parsed.repository) {
|
|
1651
|
+
addCheck(
|
|
1652
|
+
ctx,
|
|
1653
|
+
"dep-repository",
|
|
1654
|
+
"dependencies",
|
|
1655
|
+
"package.json has a repository field",
|
|
1656
|
+
"info",
|
|
1657
|
+
"No `repository` field. npm and other tools cannot link back to the source.",
|
|
1658
|
+
{ file: "package.json" },
|
|
1659
|
+
);
|
|
1660
|
+
} else {
|
|
1661
|
+
addCheck(
|
|
1662
|
+
ctx,
|
|
1663
|
+
"dep-repository",
|
|
1664
|
+
"dependencies",
|
|
1665
|
+
"package.json has a repository field",
|
|
1666
|
+
"pass",
|
|
1667
|
+
"Repository field is set.",
|
|
1668
|
+
{ file: "package.json" },
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
if (ctx.repo.extraLockfiles.length > 0) {
|
|
1675
|
+
addCheck(
|
|
1676
|
+
ctx,
|
|
1677
|
+
"dep-lockfile",
|
|
1678
|
+
"dependencies",
|
|
1679
|
+
"Single lockfile",
|
|
1680
|
+
"warn",
|
|
1681
|
+
`Multiple lockfiles detected: ${ctx.repo.primaryLockfile ?? "(none)"}, ${ctx.repo.extraLockfiles.join(", ")}. This causes non-deterministic installs.`,
|
|
1682
|
+
);
|
|
1683
|
+
ctx.findings.push(
|
|
1684
|
+
makeFinding(
|
|
1685
|
+
"dep-multiple-lockfiles",
|
|
1686
|
+
"Multiple lockfiles present",
|
|
1687
|
+
`Detected ${ctx.repo.extraLockfiles.length + 1} lockfiles: ${[ctx.repo.primaryLockfile, ...ctx.repo.extraLockfiles].filter(Boolean).join(", ")}. Pick one package manager to keep installs deterministic.`,
|
|
1688
|
+
"medium",
|
|
1689
|
+
"dependencies",
|
|
1690
|
+
"Delete every lockfile except the one matching your chosen package manager, then re-run `npm install` (or yarn/pnpm/bun).",
|
|
1691
|
+
),
|
|
1692
|
+
);
|
|
1693
|
+
} else if (ctx.repo.primaryLockfile) {
|
|
1694
|
+
addCheck(
|
|
1695
|
+
ctx,
|
|
1696
|
+
"dep-lockfile",
|
|
1697
|
+
"dependencies",
|
|
1698
|
+
"Single lockfile",
|
|
1699
|
+
"pass",
|
|
1700
|
+
`Lockfile: ${ctx.repo.primaryLockfile}`,
|
|
1701
|
+
);
|
|
1702
|
+
} else {
|
|
1703
|
+
addCheck(
|
|
1704
|
+
ctx,
|
|
1705
|
+
"dep-lockfile",
|
|
1706
|
+
"dependencies",
|
|
1707
|
+
"Single lockfile",
|
|
1708
|
+
"info",
|
|
1709
|
+
"No lockfile detected. Add one for reproducible installs.",
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
function checkSecrets(ctx: ScanContext): void {
|
|
1715
|
+
const MAX_FINDINGS = 200;
|
|
1716
|
+
|
|
1717
|
+
function isBinary(content: string): boolean {
|
|
1718
|
+
const sample = content.length > 8192 ? content.slice(0, 8192) : content;
|
|
1719
|
+
return sample.indexOf("\0") !== -1 || /[\x01-\x08\x0E-\x1F]/.test(sample);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function lineAtOffset(lineOffsets: number[], index: number): number {
|
|
1723
|
+
let lo = 0;
|
|
1724
|
+
let hi = lineOffsets.length - 1;
|
|
1725
|
+
while (lo < hi) {
|
|
1726
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
1727
|
+
if (lineOffsets[mid] <= index) lo = mid;
|
|
1728
|
+
else hi = mid - 1;
|
|
1729
|
+
}
|
|
1730
|
+
return lo + 1;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function buildLineOffsets(content: string): number[] {
|
|
1734
|
+
const offsets = [0];
|
|
1735
|
+
for (let i = 0; i < content.length; i++) {
|
|
1736
|
+
if (content.charCodeAt(i) === 10) offsets.push(i + 1);
|
|
1737
|
+
}
|
|
1738
|
+
return offsets;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const priorityFiles: { file: RepoFile; priority: number }[] = [];
|
|
1742
|
+
for (const file of ctx.repo.files) {
|
|
1743
|
+
if (!isLikelySecretScanPath(file.path, file.content.length)) continue;
|
|
1744
|
+
if (isTestFile(file.path.toLowerCase())) continue;
|
|
1745
|
+
if (isBinary(file.content)) continue;
|
|
1746
|
+
|
|
1747
|
+
priorityFiles.push({ file, priority: secretScanPriority(file.path) });
|
|
1748
|
+
}
|
|
1749
|
+
priorityFiles.sort((a, b) => b.priority - a.priority);
|
|
1750
|
+
for (const { file } of priorityFiles) {
|
|
1751
|
+
ctx.filesChecked.add(file.path);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
const filesWithNeedles = priorityFiles.filter(({ file }) =>
|
|
1755
|
+
fileContainsAnyNeedle(file.content),
|
|
1756
|
+
);
|
|
1757
|
+
const filesToScanForAll = priorityFiles;
|
|
1758
|
+
|
|
1759
|
+
let totalMatches = 0;
|
|
1760
|
+
const perFile: Record<string, number> = {};
|
|
1761
|
+
const perPattern: Record<string, number> = {};
|
|
1762
|
+
const NEEDLE_LESS = SECRET_PATTERNS.filter((p) => p.needles.length === 0);
|
|
1763
|
+
const NEEDLE_BASED = SECRET_PATTERNS.filter((p) => p.needles.length > 0);
|
|
1764
|
+
|
|
1765
|
+
const pushFinding = (
|
|
1766
|
+
file: RepoFile,
|
|
1767
|
+
pattern: (typeof SECRET_PATTERNS)[number],
|
|
1768
|
+
match: RegExpExecArray,
|
|
1769
|
+
offsets: number[],
|
|
1770
|
+
lines: string[],
|
|
1771
|
+
ext: string,
|
|
1772
|
+
): void => {
|
|
1773
|
+
const matchedValue = match[1] ?? match[0];
|
|
1774
|
+
if (isLikelyPlaceholder(matchedValue)) return;
|
|
1775
|
+
const startIdx = match.index;
|
|
1776
|
+
const line = lineAtOffset(offsets, startIdx);
|
|
1777
|
+
const rawLine = lines[line - 1] ?? "";
|
|
1778
|
+
if (isCommentedLine(rawLine, ext)) return;
|
|
1779
|
+
ctx.findings.push(
|
|
1780
|
+
makeFinding(
|
|
1781
|
+
`secret-${pattern.name.replace(/\s+/g, "-").toLowerCase()}-${file.path}-${line}`,
|
|
1782
|
+
`Possible ${pattern.name} found`,
|
|
1783
|
+
`${pattern.description} This is a heuristic, not a guarantee \u2014 always confirm before rotating.`,
|
|
1784
|
+
pattern.severity,
|
|
1785
|
+
"secret",
|
|
1786
|
+
`Move the value out of source: store it in a secret manager or environment variable, rotate the original value, and rewrite the file with a placeholder.`,
|
|
1787
|
+
{
|
|
1788
|
+
file: file.path,
|
|
1789
|
+
line,
|
|
1790
|
+
evidence: maskLine(rawLine).slice(0, 200),
|
|
1791
|
+
confidence: confidenceForSecretPattern(pattern.name),
|
|
1792
|
+
fingerprint: fingerprintSecret(matchedValue),
|
|
1793
|
+
fixPrompt: `Look at the file \`${file.path}\` around line ${line}. Replace the suspected secret with a placeholder and reference an environment variable instead.`,
|
|
1794
|
+
},
|
|
1795
|
+
),
|
|
1796
|
+
);
|
|
1797
|
+
const finding = ctx.findings.at(-1);
|
|
1798
|
+
if (ctx.collectSecretCandidates && finding) {
|
|
1799
|
+
ctx.secretCandidates.push({
|
|
1800
|
+
findingId: finding.id,
|
|
1801
|
+
patternName: pattern.name,
|
|
1802
|
+
value: matchedValue,
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
totalMatches++;
|
|
1806
|
+
perFile[file.path] = (perFile[file.path] ?? 0) + 1;
|
|
1807
|
+
perPattern[pattern.name] = (perPattern[pattern.name] ?? 0) + 1;
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
const scanFileWithPatterns = (
|
|
1811
|
+
file: RepoFile,
|
|
1812
|
+
patterns: typeof SECRET_PATTERNS,
|
|
1813
|
+
): void => {
|
|
1814
|
+
const offsets = buildLineOffsets(file.content);
|
|
1815
|
+
const lines = file.content.split(/\r?\n/);
|
|
1816
|
+
const ext = file.path.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "default";
|
|
1817
|
+
for (const pattern of patterns) {
|
|
1818
|
+
pattern.regex.lastIndex = 0;
|
|
1819
|
+
let match: RegExpExecArray | null;
|
|
1820
|
+
while ((match = pattern.regex.exec(file.content)) !== null) {
|
|
1821
|
+
pushFinding(file, pattern, match, offsets, lines, ext);
|
|
1822
|
+
if (ctx.findings.length > MAX_FINDINGS) {
|
|
1823
|
+
addCheck(
|
|
1824
|
+
ctx,
|
|
1825
|
+
"secret-scan",
|
|
1826
|
+
"secret",
|
|
1827
|
+
"No secret patterns in source",
|
|
1828
|
+
"fail",
|
|
1829
|
+
`Stopped after ${MAX_FINDINGS} matches. ${totalMatches} possible secret pattern(s) detected in ${priorityFiles.length} scanned file(s).`,
|
|
1830
|
+
);
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
};
|
|
1836
|
+
|
|
1837
|
+
for (const { file } of filesWithNeedles) {
|
|
1838
|
+
scanFileWithPatterns(file, NEEDLE_BASED);
|
|
1839
|
+
if (ctx.findings.length > MAX_FINDINGS) {
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if (NEEDLE_LESS.length > 0) {
|
|
1845
|
+
for (const { file } of filesToScanForAll) {
|
|
1846
|
+
scanFileWithPatterns(file, NEEDLE_LESS);
|
|
1847
|
+
if (ctx.findings.length > MAX_FINDINGS) {
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
if (totalMatches === 0) {
|
|
1854
|
+
addCheck(
|
|
1855
|
+
ctx,
|
|
1856
|
+
"secret-scan",
|
|
1857
|
+
"secret",
|
|
1858
|
+
"No secret patterns in source",
|
|
1859
|
+
"pass",
|
|
1860
|
+
`No known secret patterns matched in ${priorityFiles.length} scanned file(s).`,
|
|
1861
|
+
);
|
|
1862
|
+
} else {
|
|
1863
|
+
const top = Object.entries(perPattern)
|
|
1864
|
+
.sort((a, b) => b[1] - a[1])
|
|
1865
|
+
.slice(0, 3)
|
|
1866
|
+
.map(([name, n]) => `${name} (${n})`)
|
|
1867
|
+
.join(", ");
|
|
1868
|
+
addCheck(
|
|
1869
|
+
ctx,
|
|
1870
|
+
"secret-scan",
|
|
1871
|
+
"secret",
|
|
1872
|
+
"No secret patterns in source",
|
|
1873
|
+
"fail",
|
|
1874
|
+
`${totalMatches} possible secret pattern match(es) across ${Object.keys(perFile).length} file(s) of ${priorityFiles.length} scanned. Top: ${top}.`,
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function buildSummary(ctx: ScanContext): ScanSummary {
|
|
1880
|
+
const byCategory: Record<FindingCategory, CategoryBreakdown> = {
|
|
1881
|
+
environment: { total: 0, passed: 0, failed: 0 },
|
|
1882
|
+
documentation: { total: 0, passed: 0, failed: 0 },
|
|
1883
|
+
package: { total: 0, passed: 0, failed: 0 },
|
|
1884
|
+
github: { total: 0, passed: 0, failed: 0 },
|
|
1885
|
+
secret: { total: 0, passed: 0, failed: 0 },
|
|
1886
|
+
docker: { total: 0, passed: 0, failed: 0 },
|
|
1887
|
+
community: { total: 0, passed: 0, failed: 0 },
|
|
1888
|
+
ci: { total: 0, passed: 0, failed: 0 },
|
|
1889
|
+
metadata: { total: 0, passed: 0, failed: 0 },
|
|
1890
|
+
code: { total: 0, passed: 0, failed: 0 },
|
|
1891
|
+
dependencies: { total: 0, passed: 0, failed: 0 },
|
|
1892
|
+
};
|
|
1893
|
+
let passed = 0;
|
|
1894
|
+
let failed = 0;
|
|
1895
|
+
for (const c of ctx.checks) {
|
|
1896
|
+
byCategory[c.category].total++;
|
|
1897
|
+
if (c.status === "pass" || c.status === "info") {
|
|
1898
|
+
byCategory[c.category].passed++;
|
|
1899
|
+
passed++;
|
|
1900
|
+
} else if (c.status === "skip") {
|
|
1901
|
+
// not counted as pass or fail
|
|
1902
|
+
} else {
|
|
1903
|
+
byCategory[c.category].failed++;
|
|
1904
|
+
failed++;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
return {
|
|
1908
|
+
totalChecks: ctx.checks.length,
|
|
1909
|
+
passed,
|
|
1910
|
+
failed,
|
|
1911
|
+
totalFindings: ctx.findings.length,
|
|
1912
|
+
filesChecked: ctx.filesChecked.size,
|
|
1913
|
+
filesMissing: Array.from(new Set(ctx.filesMissing)).sort(),
|
|
1914
|
+
byCategory,
|
|
1915
|
+
checks: ctx.checks,
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function groupFindingsByFile(findings: Finding[]): FileGroup[] {
|
|
1920
|
+
const map = new Map<string, Finding[]>();
|
|
1921
|
+
for (const f of findings) {
|
|
1922
|
+
const key = f.file ?? "_general_";
|
|
1923
|
+
if (!map.has(key)) map.set(key, []);
|
|
1924
|
+
map.get(key)!.push(f);
|
|
1925
|
+
}
|
|
1926
|
+
const groups: FileGroup[] = [];
|
|
1927
|
+
for (const [path, items] of map.entries()) {
|
|
1928
|
+
const counts: Record<Severity, number> = {
|
|
1929
|
+
critical: 0,
|
|
1930
|
+
high: 0,
|
|
1931
|
+
medium: 0,
|
|
1932
|
+
low: 0,
|
|
1933
|
+
info: 0,
|
|
1934
|
+
};
|
|
1935
|
+
for (const it of items) counts[it.severity]++;
|
|
1936
|
+
groups.push({ path, findings: items, counts });
|
|
1937
|
+
}
|
|
1938
|
+
groups.sort((a, b) => {
|
|
1939
|
+
const sevOrder: Record<Severity, number> = {
|
|
1940
|
+
critical: 5,
|
|
1941
|
+
high: 4,
|
|
1942
|
+
medium: 3,
|
|
1943
|
+
low: 2,
|
|
1944
|
+
info: 1,
|
|
1945
|
+
};
|
|
1946
|
+
const aMax = Math.max(...a.findings.map((f) => sevOrder[f.severity]), 0);
|
|
1947
|
+
const bMax = Math.max(...b.findings.map((f) => sevOrder[f.severity]), 0);
|
|
1948
|
+
if (aMax !== bMax) return bMax - aMax;
|
|
1949
|
+
return b.findings.length - a.findings.length;
|
|
1950
|
+
});
|
|
1951
|
+
return groups;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
export interface ScanResult {
|
|
1955
|
+
findings: Finding[];
|
|
1956
|
+
summary: ScanSummary;
|
|
1957
|
+
filesChecked: string[];
|
|
1958
|
+
fileGroups: FileGroup[];
|
|
1959
|
+
secretCandidates?: SecretCandidate[];
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
export function runScan(
|
|
1963
|
+
repo: RepoData,
|
|
1964
|
+
options: { collectSecretCandidates?: boolean } = {},
|
|
1965
|
+
): ScanResult {
|
|
1966
|
+
const ctx: ScanContext = {
|
|
1967
|
+
repo,
|
|
1968
|
+
findings: [],
|
|
1969
|
+
checks: [],
|
|
1970
|
+
filesChecked: new Set(),
|
|
1971
|
+
filesMissing: [],
|
|
1972
|
+
secretCandidates: [],
|
|
1973
|
+
collectSecretCandidates: options.collectSecretCandidates ?? false,
|
|
1974
|
+
};
|
|
1975
|
+
|
|
1976
|
+
checkEnvironment(ctx);
|
|
1977
|
+
checkGitignore(ctx);
|
|
1978
|
+
checkDocumentation(ctx);
|
|
1979
|
+
checkPackage(ctx);
|
|
1980
|
+
checkGithub(ctx);
|
|
1981
|
+
checkDockerfile(ctx);
|
|
1982
|
+
checkCommunity(ctx);
|
|
1983
|
+
checkWorkflowQuality(ctx);
|
|
1984
|
+
checkMetadata(ctx);
|
|
1985
|
+
checkCodePatterns(ctx);
|
|
1986
|
+
checkDependencies(ctx);
|
|
1987
|
+
checkSecrets(ctx);
|
|
1988
|
+
|
|
1989
|
+
const baseline = applyRepoBaseline(ctx.findings, ctx.repo.files);
|
|
1990
|
+
if (baseline.suppressed > 0) {
|
|
1991
|
+
ctx.findings = baseline.findings;
|
|
1992
|
+
addCheck(
|
|
1993
|
+
ctx,
|
|
1994
|
+
"baseline-suppression",
|
|
1995
|
+
"secret",
|
|
1996
|
+
"RepoSec baseline applied",
|
|
1997
|
+
"info",
|
|
1998
|
+
`${baseline.suppressed} reviewed finding(s) suppressed by .reposecignore or reposec-baseline.json.`,
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const summary = buildSummary(ctx);
|
|
2003
|
+
const fileGroups = groupFindingsByFile(ctx.findings);
|
|
2004
|
+
return {
|
|
2005
|
+
findings: ctx.findings,
|
|
2006
|
+
summary,
|
|
2007
|
+
filesChecked: Array.from(ctx.filesChecked).sort(),
|
|
2008
|
+
fileGroups,
|
|
2009
|
+
secretCandidates: options.collectSecretCandidates
|
|
2010
|
+
? ctx.secretCandidates
|
|
2011
|
+
: undefined,
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
export { SEVERITY_WEIGHT };
|