Skip to content

Commit e0ae5e5

Browse files
Copilotclaude
andcommitted
feat: add bootcamp security for standalone security analysis
Completes the deterministic, LLM-free report trio. `bootcamp health` and `bootcamp metrics` already expose their generation engines as standalone commands; this adds the third — `bootcamp security` — over the `analyzeSecurityPatterns` engine that powers SECURITY.md. `bootcamp security <repo-url>` (local path or remote URL) scans the repo and reports: - findings (severity, file/line, remediation), most-severe-first - protection coverage matrix (helmet/CORS/CSP, rate limiting, input validation, SQL-injection prevention, secret handling) - security-relevant dependencies - a 0-100 score + A-F grade Flags mirror health/metrics: --json, --check/--min-score (CI gate), --branch, --max-files (routed past root-flag collisions), --keep-temp, --verbose. Tests: 9 unit (DI-mocked resolve/scan/analyze; report/JSON/check both directions/local/keep-temp/scan-failure/resolve-failure) and 3 E2E spawning the real CLI against a fixture with detectable patterns (human report, JSON shape incl. helmet/rate-limit detection, --check gate both directions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a61124e commit e0ae5e5

6 files changed

Lines changed: 519 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `bootcamp security <repo-url>` command: a standalone, deterministic security pattern analysis for any repository (local path or remote URL) without invoking the LLM. Reports detected security findings (with severity, file/line, and remediation), protection coverage (security headers, CORS, CSP, rate limiting, input validation, SQL-injection prevention, secret handling), security-relevant dependencies, and a **0-100 score + A–F grade**. Prints a human-readable report by default, supports `--json` for scripting, and offers a CI gate via `--check`/`--min-score`. Reuses the same `analyzeSecurityPatterns` engine that powers `SECURITY.md` — completing the deterministic `health`/`metrics`/`security` trio.
1213
- `bootcamp metrics <repo-url>` command: a standalone, deterministic codebase-metrics report for any repository (local path or remote URL) without invoking the LLM. Surfaces language breakdown, source/test/doc/config composition, average/median file size, test-to-source ratio, size classification, top-level directory distribution, largest-file hotspots, and an **approachability score (0-100 + A–F grade)** with human-readable drivers. Prints a human-readable report by default, supports `--json` for scripting, and offers a CI gate via `--check`/`--min-score` (exits non-zero when approachability is below the threshold). Reuses the same `computeCodebaseMetrics` engine that powers `METRICS.md` — mirroring the existing `bootcamp health` command.
1314
- Web demo file viewer now has **Copy** and **Download** buttons: copy a generated doc's contents to the clipboard (async Clipboard API with a `document.execCommand` fallback and a timeout guard) or download it as a file, directly from the preview modal.
1415
- `bootcamp completion <bash|zsh|fish>` command to print a shell completion script for tab-completing subcommands, their aliases, and option flags. The completion data is derived from the live CLI definition, so it can never drift from the actual command surface; pipe it to your shell's completion directory (or `source <(bootcamp completion bash)`).

README.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,8 @@ Onboarding Risk: 18/100 (A) 🟢
145145
| GitHub stars | 29 |
146146
| Generated files | 14+ |
147147
| Test suite | 1,000+ tests |
148-
| Source files | 51 TypeScript modules |
149-
| Test files | 76 Vitest files |
148+
| Source files | 52 TypeScript modules |
149+
| Test files | 78 Vitest files |
150150
| Lines of code | 13,381 TypeScript LOC (src/) |
151151
| Languages supported | 10+ |
152152
| Generation time | < 60 seconds |
@@ -508,6 +508,22 @@ bootcamp metrics ./my-repo --json
508508
bootcamp metrics ./my-repo --check --min-score 75
509509
```
510510

511+
### Security Analysis
512+
513+
```bash
514+
# Analyze security patterns, protections, and score a repo
515+
bootcamp security https://github.com/owner/repo
516+
517+
# Works on local paths too
518+
bootcamp security ./my-repo
519+
520+
# Machine-readable output (findings, protections, deps, score)
521+
bootcamp security ./my-repo --json
522+
523+
# CI gate: exit non-zero when the security score is below the minimum (default 70)
524+
bootcamp security ./my-repo --check --min-score 80
525+
```
526+
511527
### Auto-Create GitHub Issues
512528

513529
```bash
@@ -600,6 +616,7 @@ npm install -g @mermaid-js/mermaid-cli
600616
| `bootcamp docs <url>` | Analyze documentation drift (`--check`, `--fix`) |
601617
| `bootcamp health <url>` | Score onboarding-readiness (`--json`, `--check`, `--min-score`) |
602618
| `bootcamp metrics <url>` | Report codebase metrics & approachability (`--json`, `--check`, `--min-score`) |
619+
| `bootcamp security <url>` | Analyze security patterns & score (`--json`, `--check`, `--min-score`) |
603620
| `bootcamp init` | Scaffold a `.bootcamprc.json` config (`--force`, `--print`, `--style`) |
604621
| `bootcamp styles` | List the built-in style packs and the sections each enables (`--json`) |
605622
| `bootcamp doctor` | Diagnose your environment (`--json`) |

src/cli.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { runHealthCommand } from "./commands/health-command.js";
3232
import { runInitCommand } from "./commands/init-command.js";
3333
import { runMainCommand } from "./commands/main-command.js";
3434
import { runMetricsCommand } from "./commands/metrics-command.js";
35+
import { runSecurityCommand } from "./commands/security-command.js";
3536
import { runStylesCommand } from "./commands/styles-command.js";
3637
import { STYLE_PACK_NAMES } from "./plugins.js";
3738
import { resolveOutputFormat } from "./services/config-resolution.js";
@@ -141,6 +142,17 @@ interface MetricsActionOptions {
141142
verbose?: boolean;
142143
}
143144

145+
interface SecurityActionOptions {
146+
[key: string]: unknown;
147+
branch?: string;
148+
check?: boolean;
149+
minScore?: string;
150+
json?: boolean;
151+
maxFiles?: string;
152+
keepTemp?: boolean;
153+
verbose?: boolean;
154+
}
155+
144156
interface InitActionOptions {
145157
[key: string]: unknown;
146158
force?: boolean;
@@ -416,6 +428,34 @@ program
416428
});
417429
});
418430

431+
program
432+
.command("security <repo-url>")
433+
.description("Run deterministic security pattern analysis: findings, protections, and a 0-100 score (supports local paths)")
434+
.option("-b, --branch <branch>", "Branch to analyze", "")
435+
.option("--check", "Exit with code 1 if the security score is below --min-score (for CI)")
436+
.option("--min-score <score>", "Minimum passing security score for --check (0-100)", "70")
437+
.option("--json", "Output the security report as JSON for machine consumption")
438+
.option("-m, --max-files <number>", "Maximum files to scan")
439+
.option("--keep-temp", "Keep temporary clone directory")
440+
.option("-v, --verbose", "Show detailed output")
441+
.action(async (repoUrl: string, rawOpts) => {
442+
const opts = getActionOptions<SecurityActionOptions>(rawOpts as Command | SecurityActionOptions);
443+
// `-b/--branch` and `-m/--max-files` collide with the root command's
444+
// options, which can capture them before the subcommand does. Fall back to
445+
// reading the raw argv (same approach as `health`/`metrics`).
446+
const branch = opts.branch || getCliFlagValue(["--branch", "-b"]) || "";
447+
const maxFiles = opts.maxFiles || getCliFlagValue(["--max-files", "-m"]) || "500";
448+
await runSecurityCommand(repoUrl, {
449+
branch,
450+
check: opts.check || false,
451+
minScore: parseInt(opts.minScore || "70", 10),
452+
json: opts.json || false,
453+
maxFiles: parseInt(maxFiles, 10),
454+
keepTemp: opts.keepTemp || false,
455+
verbose: opts.verbose || false,
456+
});
457+
});
458+
419459
program
420460
.command("init")
421461
.description("Scaffold a .bootcamprc.json config file in the current directory")

src/commands/security-command.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import chalk from "chalk";
2+
import { readFile } from "fs/promises";
3+
import { join } from "path";
4+
5+
import { resolveRepo, type RepoSource } from "../repo-resolver.js";
6+
import {
7+
analyzeSecurityPatterns,
8+
getSecurityGrade,
9+
type SecurityAnalysis,
10+
type Severity,
11+
} from "../security.js";
12+
import { scanRepositoryFiles } from "../services/clone-service.js";
13+
14+
/** Options accepted by the `bootcamp security` command. */
15+
export interface SecurityCommandOptions {
16+
branch?: string;
17+
/** Emit the report as JSON for machine consumption. */
18+
json?: boolean;
19+
/** Exit non-zero when the security score is below `minScore` (CI gate). */
20+
check?: boolean;
21+
/** Minimum passing score for `--check` (0-100). Defaults to 70. */
22+
minScore?: number;
23+
/** Maximum files to scan. Defaults to 500. */
24+
maxFiles?: number;
25+
/** Keep the temporary clone (remote repos only). */
26+
keepTemp?: boolean;
27+
verbose?: boolean;
28+
}
29+
30+
const SEVERITY_ICON: Record<Severity, string> = {
31+
critical: "🔴",
32+
high: "🟠",
33+
medium: "🟡",
34+
low: "🔵",
35+
info: "⚪",
36+
};
37+
38+
const SEVERITY_ORDER: Severity[] = ["critical", "high", "medium", "low", "info"];
39+
40+
function scoreColor(score: number): typeof chalk.green {
41+
if (score >= 80) return chalk.green;
42+
if (score >= 60) return chalk.yellow;
43+
return chalk.red;
44+
}
45+
46+
function checkmark(value: boolean): string {
47+
return value ? chalk.green("✓") : chalk.dim("·");
48+
}
49+
50+
function printReport(analysis: SecurityAnalysis, repoName: string, filesScanned: number): void {
51+
const grade = getSecurityGrade(analysis.score);
52+
const emoji = analysis.score >= 80 ? "🟢" : analysis.score >= 60 ? "🟡" : "🔴";
53+
const color = scoreColor(analysis.score);
54+
55+
console.log(chalk.bold("\n🔒 Security Analysis"));
56+
console.log(chalk.dim(`Repository: ${repoName}`));
57+
console.log(chalk.dim(`Scanned ${filesScanned} files\n`));
58+
59+
console.log(`${emoji} ` + color.bold(`${analysis.score}/100 (Grade: ${grade})`));
60+
61+
const counts = SEVERITY_ORDER.map(
62+
(sev) => [sev, analysis.findings.filter((f) => f.severity === sev).length] as const
63+
).filter(([, n]) => n > 0);
64+
if (counts.length > 0) {
65+
console.log(
66+
chalk.dim("Findings: ") +
67+
counts.map(([sev, n]) => `${SEVERITY_ICON[sev]} ${n} ${sev}`).join(chalk.dim(" · "))
68+
);
69+
} else {
70+
console.log(chalk.green("No pattern-based findings detected."));
71+
}
72+
console.log();
73+
74+
console.log(chalk.bold("Protections"));
75+
console.log(
76+
` ${checkmark(analysis.headers.hasHelmet)} security headers (helmet)` +
77+
` ${checkmark(analysis.headers.hasCors)} CORS` +
78+
` ${checkmark(analysis.headers.hasCSP)} CSP`
79+
);
80+
console.log(
81+
` ${checkmark(analysis.hasRateLimiting)} rate limiting` +
82+
` ${checkmark(analysis.hasInputValidation)} input validation` +
83+
` ${checkmark(analysis.hasSqlInjectionPrevention)} SQL-injection prevention`
84+
);
85+
console.log(
86+
` ${checkmark(analysis.secretsHandling.gitignoreSecrets)} secrets git-ignored` +
87+
` ${checkmark(analysis.secretsHandling.hasEnvExample)} .env.example present`
88+
);
89+
console.log();
90+
91+
if (analysis.securityDeps.length > 0) {
92+
console.log(chalk.bold("Security dependencies"));
93+
for (const dep of analysis.securityDeps) {
94+
console.log(` ${chalk.cyan(dep.name)}` + (dep.purpose ? chalk.dim(` — ${dep.purpose}`) : ""));
95+
}
96+
console.log();
97+
}
98+
99+
if (analysis.findings.length > 0) {
100+
console.log(chalk.bold("Findings") + chalk.dim(" (most severe first)"));
101+
const sorted = [...analysis.findings].sort(
102+
(a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity)
103+
);
104+
for (const finding of sorted) {
105+
const where = finding.file ? chalk.dim(` (${finding.file}${finding.line ? `:${finding.line}` : ""})`) : "";
106+
console.log(` ${SEVERITY_ICON[finding.severity]} ` + chalk.cyan(finding.title) + where);
107+
if (finding.recommendation) {
108+
console.log(chalk.dim(` → ${finding.recommendation}`));
109+
}
110+
}
111+
console.log();
112+
}
113+
}
114+
115+
/**
116+
* Run the standalone `bootcamp security` command: clone/resolve the target
117+
* repo, scan it, run the deterministic security pattern analysis, and report
118+
* it (human or JSON). With `--check`, exits non-zero when the score is below
119+
* `--min-score`. Reuses the same `analyzeSecurityPatterns` engine that powers
120+
* `SECURITY.md` — mirroring `bootcamp health` and `bootcamp metrics`.
121+
*/
122+
export async function runSecurityCommand(repoUrl: string, opts: SecurityCommandOptions): Promise<void> {
123+
const minScore = typeof opts.minScore === "number" && Number.isFinite(opts.minScore) ? opts.minScore : 70;
124+
125+
let repoSource: RepoSource;
126+
try {
127+
repoSource = await resolveRepo(repoUrl, process.cwd(), opts.branch || undefined);
128+
} catch (error: unknown) {
129+
console.error(
130+
chalk.red(`Failed to resolve repository: ${error instanceof Error ? error.message : String(error)}`)
131+
);
132+
process.exit(1);
133+
return;
134+
}
135+
136+
let exitCode = 0;
137+
try {
138+
const scan = await scanRepositoryFiles(repoSource.path, opts.maxFiles ?? 500);
139+
const packageJson = await readFile(join(repoSource.path, "package.json"), "utf-8")
140+
.then((content) => JSON.parse(content) as Record<string, unknown>)
141+
.catch(() => undefined);
142+
const analysis = await analyzeSecurityPatterns(repoSource.path, scan.files, packageJson);
143+
const filesScanned = scan.files.length;
144+
145+
if (opts.json) {
146+
console.log(
147+
JSON.stringify(
148+
{
149+
repo: repoSource.repoInfo.fullName,
150+
filesScanned,
151+
grade: getSecurityGrade(analysis.score),
152+
...analysis,
153+
},
154+
null,
155+
2
156+
)
157+
);
158+
} else {
159+
printReport(analysis, repoSource.repoInfo.fullName, filesScanned);
160+
}
161+
162+
if (opts.check && analysis.score < minScore) {
163+
if (!opts.json) {
164+
console.error(
165+
chalk.red(`❌ Security score ${analysis.score}/100 is below the required minimum of ${minScore}.`)
166+
);
167+
}
168+
exitCode = 1;
169+
}
170+
} catch (error: unknown) {
171+
console.error(
172+
chalk.red(`Security analysis failed: ${error instanceof Error ? error.message : String(error)}`)
173+
);
174+
exitCode = 1;
175+
} finally {
176+
if (opts.keepTemp && !repoSource.isLocal) {
177+
console.log(chalk.gray(`Temporary clone kept at: ${repoSource.path}`));
178+
} else {
179+
await repoSource.cleanup();
180+
}
181+
}
182+
183+
if (exitCode !== 0) {
184+
process.exit(exitCode);
185+
}
186+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { execFileSync } from "child_process";
2+
import { mkdtemp, mkdir, rm, writeFile } from "fs/promises";
3+
import { tmpdir } from "os";
4+
import { join } from "path";
5+
6+
import { afterEach, describe, expect, it } from "vitest";
7+
8+
import { runCli } from "./helpers.js";
9+
10+
async function createRepo(
11+
baseDir: string,
12+
files: Record<string, string>
13+
): Promise<string> {
14+
const repoDir = join(baseDir, "fixture-security-repo");
15+
await mkdir(repoDir, { recursive: true });
16+
17+
for (const [relativePath, content] of Object.entries(files)) {
18+
const fullPath = join(repoDir, relativePath);
19+
await mkdir(join(fullPath, ".."), { recursive: true });
20+
await writeFile(fullPath, content, "utf-8");
21+
}
22+
23+
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, stdio: "ignore" });
24+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoDir, stdio: "ignore" });
25+
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoDir, stdio: "ignore" });
26+
execFileSync("git", ["add", "-A"], { cwd: repoDir, stdio: "ignore" });
27+
execFileSync("git", ["commit", "-m", "init", "--no-gpg-sign"], { cwd: repoDir, stdio: "ignore" });
28+
29+
return repoDir;
30+
}
31+
32+
const SECURE_FILES: Record<string, string> = {
33+
"package.json": JSON.stringify(
34+
{
35+
name: "fixture-security-repo",
36+
version: "1.0.0",
37+
dependencies: { helmet: "^8.0.0", "express-rate-limit": "^8.0.0", zod: "^4.0.0" },
38+
},
39+
null,
40+
2
41+
),
42+
"README.md": "# Fixture Security Repo\n",
43+
".gitignore": ".env\nnode_modules\n",
44+
".env.example": "API_KEY=\n",
45+
"src/index.ts":
46+
'import helmet from "helmet";\nimport rateLimit from "express-rate-limit";\nexport const x = process.env.PORT;\n',
47+
};
48+
49+
describe("security command", () => {
50+
const tempDirs: string[] = [];
51+
52+
afterEach(async () => {
53+
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
54+
tempDirs.length = 0;
55+
});
56+
57+
it("prints a human-readable security report for a local repo", async () => {
58+
const tempDir = await mkdtemp(join(tmpdir(), "bootcamp-security-e2e-"));
59+
tempDirs.push(tempDir);
60+
const repoPath = await createRepo(tempDir, SECURE_FILES);
61+
62+
const result = await runCli(["security", repoPath]);
63+
expect(result.exitCode).toBe(0);
64+
expect(result.stdout).toContain("Security Analysis");
65+
expect(result.stdout).toContain("/100");
66+
expect(result.stdout).toContain("Protections");
67+
}, 60_000);
68+
69+
it("emits machine-readable JSON with --json", async () => {
70+
const tempDir = await mkdtemp(join(tmpdir(), "bootcamp-security-e2e-"));
71+
tempDirs.push(tempDir);
72+
const repoPath = await createRepo(tempDir, SECURE_FILES);
73+
74+
const result = await runCli(["security", repoPath, "--json"]);
75+
expect(result.exitCode).toBe(0);
76+
77+
const parsed = JSON.parse(result.stdout);
78+
expect(typeof parsed.score).toBe("number");
79+
expect(typeof parsed.grade).toBe("string");
80+
expect(Array.isArray(parsed.findings)).toBe(true);
81+
expect(parsed.headers.hasHelmet).toBe(true);
82+
expect(parsed.hasRateLimiting).toBe(true);
83+
expect(parsed.securityDeps.some((d: { name: string }) => d.name === "helmet")).toBe(true);
84+
}, 60_000);
85+
86+
it("supports the --check gate (passes with a low minimum, fails with an impossible one)", async () => {
87+
const tempDir = await mkdtemp(join(tmpdir(), "bootcamp-security-e2e-"));
88+
tempDirs.push(tempDir);
89+
const repoPath = await createRepo(tempDir, SECURE_FILES);
90+
91+
const passing = await runCli(["security", repoPath, "--check", "--min-score", "0"]);
92+
expect(passing.exitCode).toBe(0);
93+
94+
const failing = await runCli(["security", repoPath, "--check", "--min-score", "101"]);
95+
expect(failing.exitCode).toBe(1);
96+
expect(`${failing.stdout}\n${failing.stderr}`).toContain("below the required minimum");
97+
}, 60_000);
98+
});

0 commit comments

Comments
 (0)