bootsec 0.8.0__py3-none-any.whl
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.
- bootsec/__init__.py +1 -0
- bootsec/checks.py +174 -0
- bootsec/cli.py +371 -0
- bootsec/i18n.py +114 -0
- bootsec/loader.py +27 -0
- bootsec/merge.py +26 -0
- bootsec/packs.py +297 -0
- bootsec/profiles.py +51 -0
- bootsec/renderer.py +281 -0
- bootsec/signals.py +191 -0
- bootsec/templates/__init__.py +0 -0
- bootsec/templates/common/__init__.py +0 -0
- bootsec/templates/common/checklist.md.tmpl +23 -0
- bootsec/templates/common/env.example.tmpl +16 -0
- bootsec/templates/common/github-actions-security.yml.tmpl +28 -0
- bootsec/templates/common/gitignore.block.tmpl +35 -0
- bootsec/templates/common/pre-commit-config.yaml.tmpl +10 -0
- bootsec/templates/common/pre-commit-with-secret-scanner.yaml.tmpl +15 -0
- bootsec/templates/common/security.md.tmpl +43 -0
- bootsec/templates/dotnet/gitignore.extra.tmpl +29 -0
- bootsec/templates/flutter/__init__.py +0 -0
- bootsec/templates/flutter/gitignore.extra.tmpl +19 -0
- bootsec/templates/go/checklist.extra.tmpl +13 -0
- bootsec/templates/go/gitignore.extra.tmpl +10 -0
- bootsec/templates/java/checklist.extra.tmpl +13 -0
- bootsec/templates/java/gitignore.extra.tmpl +30 -0
- bootsec/templates/node/__init__.py +0 -0
- bootsec/templates/node/checklist.extra.tmpl +13 -0
- bootsec/templates/node/gitignore.extra.tmpl +17 -0
- bootsec/templates/packs/api-backend/checklist.md.tmpl +12 -0
- bootsec/templates/packs/api-backend/security.md.tmpl +3 -0
- bootsec/templates/packs/cli-tool/checklist.md.tmpl +12 -0
- bootsec/templates/packs/cli-tool/security.md.tmpl +6 -0
- bootsec/templates/packs/core-baseline/checklist.md.tmpl +18 -0
- bootsec/templates/packs/core-baseline/security.md.tmpl +7 -0
- bootsec/templates/packs/enterprise-lite/baseline.md.tmpl +36 -0
- bootsec/templates/packs/enterprise-lite/checklist.md.tmpl +10 -0
- bootsec/templates/packs/enterprise-lite/evidence.md.tmpl +31 -0
- bootsec/templates/packs/enterprise-lite/security.md.tmpl +3 -0
- bootsec/templates/packs/global-baseline/baseline.md.tmpl +36 -0
- bootsec/templates/packs/global-baseline/checklist.md.tmpl +10 -0
- bootsec/templates/packs/global-baseline/evidence.md.tmpl +25 -0
- bootsec/templates/packs/global-baseline/security.md.tmpl +3 -0
- bootsec/templates/packs/health-data/baseline.md.tmpl +26 -0
- bootsec/templates/packs/health-data/checklist.md.tmpl +13 -0
- bootsec/templates/packs/health-data/evidence.md.tmpl +11 -0
- bootsec/templates/packs/health-data/security.md.tmpl +3 -0
- bootsec/templates/packs/mobile-app/checklist.md.tmpl +13 -0
- bootsec/templates/packs/mobile-app/security.md.tmpl +3 -0
- bootsec/templates/packs/payments/baseline.md.tmpl +26 -0
- bootsec/templates/packs/payments/checklist.md.tmpl +13 -0
- bootsec/templates/packs/payments/evidence.md.tmpl +11 -0
- bootsec/templates/packs/payments/security.md.tmpl +3 -0
- bootsec/templates/packs/pii/checklist.md.tmpl +14 -0
- bootsec/templates/packs/pii/security.md.tmpl +6 -0
- bootsec/templates/packs/sa-baseline/baseline.md.tmpl +33 -0
- bootsec/templates/packs/sa-baseline/checklist.md.tmpl +10 -0
- bootsec/templates/packs/sa-baseline/evidence.md.tmpl +25 -0
- bootsec/templates/packs/sa-baseline/security.md.tmpl +3 -0
- bootsec/templates/packs/startup-saas/checklist.md.tmpl +12 -0
- bootsec/templates/packs/startup-saas/security.md.tmpl +3 -0
- bootsec/templates/packs/web-app/checklist.md.tmpl +13 -0
- bootsec/templates/packs/web-app/security.md.tmpl +6 -0
- bootsec/templates/php/checklist.extra.tmpl +13 -0
- bootsec/templates/php/gitignore.extra.tmpl +18 -0
- bootsec/templates/python/__init__.py +0 -0
- bootsec/templates/python/checklist.extra.tmpl +13 -0
- bootsec/templates/python/gitignore.extra.tmpl +29 -0
- bootsec/templates/ruby/checklist.extra.tmpl +13 -0
- bootsec/templates/ruby/gitignore.extra.tmpl +34 -0
- bootsec/templates/rust/checklist.extra.tmpl +13 -0
- bootsec/templates/rust/gitignore.extra.tmpl +5 -0
- bootsec/ui.py +433 -0
- bootsec-0.8.0.dist-info/METADATA +145 -0
- bootsec-0.8.0.dist-info/RECORD +79 -0
- bootsec-0.8.0.dist-info/WHEEL +4 -0
- bootsec-0.8.0.dist-info/entry_points.txt +2 -0
- bootsec-0.8.0.dist-info/licenses/LICENSE +21 -0
- bootsec-0.8.0.dist-info/licenses/NOTICE +30 -0
bootsec/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.0"
|
bootsec/checks.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Security checks for bootsec (free version).
|
|
2
|
+
|
|
3
|
+
FAST mode only - for guard command.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Limits
|
|
12
|
+
FILE_READ_CAP = 64 * 1024 # 64KB per file
|
|
13
|
+
|
|
14
|
+
# Directories to always skip
|
|
15
|
+
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "dist", "build", ".dart_tool", "__pycache__", ".next", ".nuxt"}
|
|
16
|
+
|
|
17
|
+
# Signal files for fast mode
|
|
18
|
+
SIGNAL_FILES = [
|
|
19
|
+
"package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
|
|
20
|
+
"pyproject.toml", "requirements.txt", "poetry.lock",
|
|
21
|
+
"pubspec.yaml", "pubspec.lock",
|
|
22
|
+
"README.md",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Risky patterns (conservative for fast mode)
|
|
26
|
+
ENV_FILE_PATTERN = re.compile(r"^\.env(\..+)?$")
|
|
27
|
+
PRIVATE_KEY_PATTERN = re.compile(r"-----BEGIN\s+(RSA|DSA|EC|OPENSSH|PGP|ENCRYPTED)?\s*PRIVATE\s+KEY-----")
|
|
28
|
+
|
|
29
|
+
# Core secret patterns for fast mode
|
|
30
|
+
SECRET_PATTERNS = [
|
|
31
|
+
# AWS
|
|
32
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "AWS Access Key", "high", 25),
|
|
33
|
+
# GitHub
|
|
34
|
+
(re.compile(r"ghp_[a-zA-Z0-9]{36}"), "GitHub PAT", "high", 25),
|
|
35
|
+
(re.compile(r"github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}"), "GitHub PAT (fine-grained)", "high", 25),
|
|
36
|
+
# Stripe
|
|
37
|
+
(re.compile(r"sk_live_[a-zA-Z0-9]{24,}"), "Stripe Live Key", "high", 25),
|
|
38
|
+
# Slack
|
|
39
|
+
(re.compile(r"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*"), "Slack Token", "high", 25),
|
|
40
|
+
# Database URLs
|
|
41
|
+
(re.compile(r"(?i)(mongodb(\+srv)?|postgres(ql)?|mysql|redis)://[^:\s]+:[^@\s]+@[^\s]+"), "Database URL with credentials", "high", 25),
|
|
42
|
+
# Private keys
|
|
43
|
+
(re.compile(r"-----BEGIN\s+(RSA|DSA|EC|OPENSSH)?\s*PRIVATE\s+KEY-----"), "Private Key", "high", 25),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# False positive contexts
|
|
47
|
+
FALSE_POSITIVE_CONTEXTS = [
|
|
48
|
+
re.compile(r"(?i)(example|sample|test|dummy|fake|placeholder|your[_-]?|my[_-]?|xxx|replace[_-]?me)"),
|
|
49
|
+
re.compile(r"<[A-Z_]+>"),
|
|
50
|
+
re.compile(r"\$\{[^}]+\}"),
|
|
51
|
+
re.compile(r"\{\{[^}]+\}\}"),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Finding:
|
|
57
|
+
name: str
|
|
58
|
+
severity: str # "high", "med", "low"
|
|
59
|
+
points: int
|
|
60
|
+
source: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CheckResult:
|
|
65
|
+
mode: str # "fast"
|
|
66
|
+
findings: list[Finding] = field(default_factory=list)
|
|
67
|
+
files_scanned: int = 0
|
|
68
|
+
bytes_scanned: int = 0
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def score(self) -> int:
|
|
72
|
+
total = sum(f.points for f in self.findings)
|
|
73
|
+
return max(0, min(100, 100 - total))
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def high_count(self) -> int:
|
|
77
|
+
return sum(1 for f in self.findings if f.severity == "high")
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def med_count(self) -> int:
|
|
81
|
+
return sum(1 for f in self.findings if f.severity == "med")
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def low_count(self) -> int:
|
|
85
|
+
return sum(1 for f in self.findings if f.severity == "low")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def fast_checks(directory: Path) -> CheckResult:
|
|
89
|
+
"""Fast mode: signal files only, high-confidence issues."""
|
|
90
|
+
result = CheckResult(mode="fast")
|
|
91
|
+
|
|
92
|
+
# Check for .env files in root
|
|
93
|
+
for item in directory.iterdir():
|
|
94
|
+
if item.is_file() and ENV_FILE_PATTERN.match(item.name):
|
|
95
|
+
if item.name != ".env.example":
|
|
96
|
+
result.findings.append(Finding(
|
|
97
|
+
f".env file tracked: {item.name}",
|
|
98
|
+
"high", 25, item.name
|
|
99
|
+
))
|
|
100
|
+
|
|
101
|
+
# Check baseline docs
|
|
102
|
+
_check_baseline_docs(directory, result)
|
|
103
|
+
|
|
104
|
+
# Check staged files for secrets
|
|
105
|
+
_check_staged_secrets(directory, result)
|
|
106
|
+
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _check_baseline_docs(directory: Path, result: CheckResult) -> None:
|
|
111
|
+
"""Check for missing baseline docs."""
|
|
112
|
+
checks = [
|
|
113
|
+
(".gitignore", "gitignore", "high", 20),
|
|
114
|
+
("SECURITY.md", "security policy", "high", 20),
|
|
115
|
+
(".pre-commit-config.yaml", "commit guard", "med", 8),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
for path, name, severity, points in checks:
|
|
119
|
+
fpath = directory / path
|
|
120
|
+
if not fpath.exists():
|
|
121
|
+
result.findings.append(Finding(name, severity, points, path))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _check_staged_secrets(directory: Path, result: CheckResult) -> None:
|
|
125
|
+
"""Check staged files for secrets."""
|
|
126
|
+
import subprocess
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
proc = subprocess.run(
|
|
130
|
+
["git", "diff", "--cached", "--name-only"],
|
|
131
|
+
cwd=directory,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
timeout=5,
|
|
135
|
+
)
|
|
136
|
+
if proc.returncode != 0:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
staged_files = [f for f in proc.stdout.strip().split("\n") if f]
|
|
140
|
+
except Exception:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
for relpath in staged_files:
|
|
144
|
+
fpath = directory / relpath
|
|
145
|
+
if not fpath.exists() or not fpath.is_file():
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
content = fpath.read_text(errors="ignore")[:FILE_READ_CAP]
|
|
150
|
+
_check_content_secrets(content, relpath, result)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _check_content_secrets(content: str, filepath: str, result: CheckResult) -> None:
|
|
156
|
+
"""Scan content for secrets."""
|
|
157
|
+
for pattern, name, severity, points in SECRET_PATTERNS:
|
|
158
|
+
matches = pattern.findall(content)
|
|
159
|
+
for match in matches:
|
|
160
|
+
match_str = match if isinstance(match, str) else match[0]
|
|
161
|
+
|
|
162
|
+
# Check for false positives
|
|
163
|
+
context_start = max(0, content.find(match_str) - 50)
|
|
164
|
+
context_end = min(len(content), content.find(match_str) + len(match_str) + 50)
|
|
165
|
+
context = content[context_start:context_end]
|
|
166
|
+
|
|
167
|
+
is_false_positive = any(fp.search(context) for fp in FALSE_POSITIVE_CONTEXTS)
|
|
168
|
+
|
|
169
|
+
if not is_false_positive:
|
|
170
|
+
result.findings.append(Finding(
|
|
171
|
+
f"{name} in staged file",
|
|
172
|
+
severity, points, filepath
|
|
173
|
+
))
|
|
174
|
+
break # One finding per pattern per file
|
bootsec/cli.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Bootsec CLI - Free version.
|
|
2
|
+
|
|
3
|
+
Security baseline for your project. One command, you're set.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import random
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from bootsec.checks import fast_checks
|
|
14
|
+
from bootsec.profiles import Profile, get_profile
|
|
15
|
+
from bootsec.renderer import detect_project_type, generate_files
|
|
16
|
+
from bootsec.packs import suggest_packs, select_packs_by_layer, list_packs
|
|
17
|
+
|
|
18
|
+
VERSION = "0.8.0"
|
|
19
|
+
BOOTSEC_DIR = ".bootsec"
|
|
20
|
+
|
|
21
|
+
# UI
|
|
22
|
+
BAR_WIDTH = 24
|
|
23
|
+
CHICK_SPLASH = "🐤"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def score_bar(score: int) -> str:
|
|
27
|
+
"""Generate ASCII progress bar."""
|
|
28
|
+
filled = int((score / 100) * BAR_WIDTH)
|
|
29
|
+
filled = max(0, min(BAR_WIDTH, filled))
|
|
30
|
+
empty = BAR_WIDTH - filled
|
|
31
|
+
return "█" * filled + "░" * empty
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def omar_comment(score: int) -> str:
|
|
35
|
+
"""Quick feedback based on score."""
|
|
36
|
+
if score >= 90:
|
|
37
|
+
return "Beast mode."
|
|
38
|
+
elif score >= 75:
|
|
39
|
+
return "Solid."
|
|
40
|
+
elif score >= 60:
|
|
41
|
+
return "Decent. Fix the highs."
|
|
42
|
+
elif score >= 40:
|
|
43
|
+
return "Needs work."
|
|
44
|
+
else:
|
|
45
|
+
return "Fix the red items first."
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_splash():
|
|
49
|
+
"""Print minimal splash."""
|
|
50
|
+
print(f"\n {CHICK_SPLASH} bootsec v{VERSION}\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_seal():
|
|
54
|
+
"""Print success seal."""
|
|
55
|
+
print(f" {CHICK_SPLASH}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def print_header(cmd: str):
|
|
59
|
+
"""Print minimal header."""
|
|
60
|
+
print(f"\n -- {cmd} --\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_help():
|
|
64
|
+
"""Print help."""
|
|
65
|
+
print(f"""
|
|
66
|
+
bootsec v{VERSION} — security baseline for your project
|
|
67
|
+
|
|
68
|
+
Commands:
|
|
69
|
+
go Apply baseline docs + commit guard
|
|
70
|
+
guard Block commits with issues (pre-commit, <1s)
|
|
71
|
+
peek Preview what go would create
|
|
72
|
+
review Preview coverage layers
|
|
73
|
+
packs List all available packs
|
|
74
|
+
|
|
75
|
+
Flags:
|
|
76
|
+
--full Allow extra packs
|
|
77
|
+
--ci Include GitHub Actions
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
bootsec go
|
|
81
|
+
bootsec go --ci
|
|
82
|
+
bootsec peek
|
|
83
|
+
|
|
84
|
+
─────────────────────────────────────────────────
|
|
85
|
+
Want more? Deep scans, vulnerability checks, AI fixes
|
|
86
|
+
|
|
87
|
+
→ Get bootsec Pro: https://bootsec.dev
|
|
88
|
+
─────────────────────────────────────────────────
|
|
89
|
+
""")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def print_upsell():
|
|
93
|
+
"""Show upsell message."""
|
|
94
|
+
print("""
|
|
95
|
+
─
|
|
96
|
+
🔓 Unlock: deep scans, vuln checks, SBOM, AI fixes
|
|
97
|
+
→ bootsec Pro: https://bootsec.dev
|
|
98
|
+
""")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def maybe_show_upsell():
|
|
102
|
+
"""Show upsell 1 in 3 times."""
|
|
103
|
+
if random.randint(1, 3) == 1:
|
|
104
|
+
print_upsell()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def write_manifest(directory: Path, pack_names: list[str], project_type: str, reasons: list[str]) -> None:
|
|
108
|
+
"""Write manifest file."""
|
|
109
|
+
baseline_dir = directory / "docs" / "baseline"
|
|
110
|
+
baseline_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
manifest = {
|
|
113
|
+
"version": VERSION,
|
|
114
|
+
"project_type": project_type,
|
|
115
|
+
"packs": pack_names,
|
|
116
|
+
"reasons": reasons,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
manifest_path = baseline_dir / "manifest.json"
|
|
120
|
+
manifest_path.write_text(json.dumps(manifest, indent=2))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
# Commands
|
|
125
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
def cmd_go(args: list[str]) -> int:
|
|
128
|
+
"""Full setup: docs + commit guard."""
|
|
129
|
+
directory = Path.cwd()
|
|
130
|
+
full_mode = "--full" in args
|
|
131
|
+
include_ci = "--ci" in args
|
|
132
|
+
|
|
133
|
+
print_splash()
|
|
134
|
+
|
|
135
|
+
# Detect project type
|
|
136
|
+
project_type = detect_project_type(directory)
|
|
137
|
+
print(f" • Detected: {project_type}")
|
|
138
|
+
|
|
139
|
+
# Select packs
|
|
140
|
+
suggestions = suggest_packs(directory)
|
|
141
|
+
selection = select_packs_by_layer(suggestions, full_mode=full_mode)
|
|
142
|
+
pack_names = selection.all_packs()
|
|
143
|
+
reasons = []
|
|
144
|
+
for s in suggestions:
|
|
145
|
+
if s.pack_name in pack_names:
|
|
146
|
+
reasons.append(f"{s.reason} → {s.pack_name}")
|
|
147
|
+
print(f" • Packs: {', '.join(pack_names)}")
|
|
148
|
+
|
|
149
|
+
# Create profile
|
|
150
|
+
profile = Profile(
|
|
151
|
+
name="startup",
|
|
152
|
+
gitignore=True,
|
|
153
|
+
env_example=True,
|
|
154
|
+
security_md=True,
|
|
155
|
+
checklist=True,
|
|
156
|
+
pre_commit=True,
|
|
157
|
+
github_actions=include_ci,
|
|
158
|
+
with_secret_scanner=False,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Generate files
|
|
162
|
+
results = generate_files(directory, profile, project_type, dry_run=False, pack_names=pack_names)
|
|
163
|
+
print(f" • Files: {len(results)} ready")
|
|
164
|
+
|
|
165
|
+
# Write manifest
|
|
166
|
+
write_manifest(directory, pack_names, project_type, reasons)
|
|
167
|
+
|
|
168
|
+
# Count actions
|
|
169
|
+
created = sum(1 for r in results if r.action == "create")
|
|
170
|
+
updated = sum(1 for r in results if r.action == "update")
|
|
171
|
+
|
|
172
|
+
# Install pre-commit
|
|
173
|
+
precommit_config = directory / ".pre-commit-config.yaml"
|
|
174
|
+
if precommit_config.exists():
|
|
175
|
+
try:
|
|
176
|
+
subprocess.run(
|
|
177
|
+
["pre-commit", "install"],
|
|
178
|
+
cwd=directory,
|
|
179
|
+
capture_output=True,
|
|
180
|
+
timeout=30,
|
|
181
|
+
)
|
|
182
|
+
print(" • pre-commit installed")
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
print()
|
|
187
|
+
print(f" Done. {created} created, {updated} updated.")
|
|
188
|
+
print_seal()
|
|
189
|
+
maybe_show_upsell()
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cmd_guard(args: list[str]) -> int:
|
|
194
|
+
"""Pre-commit guard check."""
|
|
195
|
+
directory = Path.cwd()
|
|
196
|
+
|
|
197
|
+
result = fast_checks(directory)
|
|
198
|
+
|
|
199
|
+
if result.high_count > 0:
|
|
200
|
+
bar = score_bar(result.score)
|
|
201
|
+
|
|
202
|
+
print(f"\n ✗ Guard failed [{bar}] {result.score}")
|
|
203
|
+
print()
|
|
204
|
+
|
|
205
|
+
for f in result.findings[:5]:
|
|
206
|
+
severity_mark = "!" if f.severity == "high" else "~"
|
|
207
|
+
print(f" {severity_mark} {f.name}")
|
|
208
|
+
|
|
209
|
+
if len(result.findings) > 5:
|
|
210
|
+
print(f" ... and {len(result.findings) - 5} more")
|
|
211
|
+
|
|
212
|
+
print()
|
|
213
|
+
return 1
|
|
214
|
+
else:
|
|
215
|
+
print_seal()
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_peek(args: list[str]) -> int:
|
|
220
|
+
"""Preview what go would create."""
|
|
221
|
+
directory = Path.cwd()
|
|
222
|
+
full_mode = "--full" in args
|
|
223
|
+
include_ci = "--ci" in args
|
|
224
|
+
|
|
225
|
+
print_splash()
|
|
226
|
+
|
|
227
|
+
project_type = detect_project_type(directory)
|
|
228
|
+
print(f" • Stack: {project_type}")
|
|
229
|
+
|
|
230
|
+
suggestions = suggest_packs(directory)
|
|
231
|
+
selection = select_packs_by_layer(suggestions, full_mode=full_mode)
|
|
232
|
+
pack_names = selection.all_packs()
|
|
233
|
+
print(f" • Packs: {', '.join(pack_names)}")
|
|
234
|
+
print()
|
|
235
|
+
|
|
236
|
+
profile = Profile(
|
|
237
|
+
name="startup",
|
|
238
|
+
gitignore=True,
|
|
239
|
+
env_example=True,
|
|
240
|
+
security_md=True,
|
|
241
|
+
checklist=True,
|
|
242
|
+
pre_commit=True,
|
|
243
|
+
github_actions=include_ci,
|
|
244
|
+
with_secret_scanner=False,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
results = generate_files(directory, profile, project_type, dry_run=True, pack_names=pack_names)
|
|
248
|
+
|
|
249
|
+
print(" Would create/update:")
|
|
250
|
+
for r in results:
|
|
251
|
+
print(f" [{r.action}] {r.path}")
|
|
252
|
+
|
|
253
|
+
print()
|
|
254
|
+
print(f" Total: {len(results)} files")
|
|
255
|
+
print()
|
|
256
|
+
maybe_show_upsell()
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cmd_review(args: list[str]) -> int:
|
|
261
|
+
"""Preview coverage layers."""
|
|
262
|
+
directory = Path.cwd()
|
|
263
|
+
full_mode = "--full" in args
|
|
264
|
+
|
|
265
|
+
print_splash()
|
|
266
|
+
|
|
267
|
+
suggestions = suggest_packs(directory)
|
|
268
|
+
selection = select_packs_by_layer(suggestions, full_mode=full_mode)
|
|
269
|
+
pack_names = selection.all_packs()
|
|
270
|
+
|
|
271
|
+
print(" Coverage layers:\n")
|
|
272
|
+
for s in suggestions:
|
|
273
|
+
if s.pack_name in pack_names:
|
|
274
|
+
print(f" • {s.pack_name}")
|
|
275
|
+
print(f" {s.reason}")
|
|
276
|
+
print()
|
|
277
|
+
|
|
278
|
+
maybe_show_upsell()
|
|
279
|
+
return 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_packs(args: list[str]) -> int:
|
|
283
|
+
"""List all available packs."""
|
|
284
|
+
print_splash()
|
|
285
|
+
print(" Available packs:\n")
|
|
286
|
+
|
|
287
|
+
for pack in list_packs():
|
|
288
|
+
print(f" • {pack.name}")
|
|
289
|
+
if pack.description:
|
|
290
|
+
print(f" {pack.description}")
|
|
291
|
+
|
|
292
|
+
print()
|
|
293
|
+
maybe_show_upsell()
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_version(args: list[str]) -> int:
|
|
298
|
+
"""Show version."""
|
|
299
|
+
print(f"bootsec {VERSION}")
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
304
|
+
# Main
|
|
305
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
COMMANDS = {
|
|
308
|
+
"go": cmd_go,
|
|
309
|
+
"guard": cmd_guard,
|
|
310
|
+
"peek": cmd_peek,
|
|
311
|
+
"review": cmd_review,
|
|
312
|
+
"packs": cmd_packs,
|
|
313
|
+
"version": cmd_version,
|
|
314
|
+
# Aliases
|
|
315
|
+
"init": cmd_go,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# Pro commands that show upsell
|
|
319
|
+
PRO_COMMANDS = {"check", "scan", "deps", "sbom", "ai", "audit", "deep"}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def main() -> int:
|
|
323
|
+
"""Main entry point."""
|
|
324
|
+
args = sys.argv[1:]
|
|
325
|
+
|
|
326
|
+
# Handle flags
|
|
327
|
+
if not args or args[0] in ("--help", "-h", "help", "hey"):
|
|
328
|
+
print_help()
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
if args[0] in ("--version", "-v", "-V"):
|
|
332
|
+
return cmd_version(args)
|
|
333
|
+
|
|
334
|
+
cmd = args[0].lower()
|
|
335
|
+
cmd_args = args[1:]
|
|
336
|
+
|
|
337
|
+
# Check for Pro command
|
|
338
|
+
if cmd in PRO_COMMANDS:
|
|
339
|
+
print(f"""
|
|
340
|
+
'{cmd}' is a Pro feature.
|
|
341
|
+
|
|
342
|
+
Get bootsec Pro for:
|
|
343
|
+
• Deep security scans
|
|
344
|
+
• Vulnerability detection (OSV)
|
|
345
|
+
• Dependency audits
|
|
346
|
+
• SBOM generation
|
|
347
|
+
• AI-powered fix suggestions
|
|
348
|
+
|
|
349
|
+
→ https://bootsec.dev
|
|
350
|
+
""")
|
|
351
|
+
return 0
|
|
352
|
+
|
|
353
|
+
# Run command
|
|
354
|
+
if cmd in COMMANDS:
|
|
355
|
+
try:
|
|
356
|
+
return COMMANDS[cmd](cmd_args)
|
|
357
|
+
except KeyboardInterrupt:
|
|
358
|
+
print("\n Cancelled.")
|
|
359
|
+
return 130
|
|
360
|
+
except Exception as e:
|
|
361
|
+
print(f" Error: {e}")
|
|
362
|
+
return 1
|
|
363
|
+
|
|
364
|
+
# Unknown command
|
|
365
|
+
print(f" Unknown command: {cmd}")
|
|
366
|
+
print(" Run 'bootsec help' for usage.")
|
|
367
|
+
return 1
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
if __name__ == "__main__":
|
|
371
|
+
sys.exit(main())
|
bootsec/i18n.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Internationalization for Bootsec CLI output.
|
|
2
|
+
|
|
3
|
+
Omar voice: 27-year-old builder, nerdy, confident, friendly.
|
|
4
|
+
80% clear + direct, 20% humor (max 1 line per section).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
LANG_AR = "ar"
|
|
11
|
+
LANG_EN = "en"
|
|
12
|
+
|
|
13
|
+
# Phrasing library - consistent voice across all output
|
|
14
|
+
MESSAGES = {
|
|
15
|
+
# Success/status - only place for emojis
|
|
16
|
+
"all_good": {"en": "All good", "ar": "يا قوت"},
|
|
17
|
+
"almost_there": {"en": "Almost there", "ar": "قريب"},
|
|
18
|
+
"fix_and_retry": {"en": "Fix and try again", "ar": "أصلح وحاول مرة ثانية"},
|
|
19
|
+
|
|
20
|
+
# Score comments (Omar style - one per score range)
|
|
21
|
+
"score_90": {"en": "Beast mode. This is clean.", "ar": "يا وحش… هذا اسمه شغل."},
|
|
22
|
+
"score_75": {"en": "Solid. A couple tweaks and you're set.", "ar": "ممتاز… باقي كم لمسة وتكمل."},
|
|
23
|
+
"score_60": {"en": "Decent. Fix the items below.", "ar": "زين… بس سوّ اللي تحت."},
|
|
24
|
+
"score_40": {"en": "Needs work. We're not here to panic, just fix it.", "ar": "نحتاج نرتّب… بس لا تقلق."},
|
|
25
|
+
"score_0": {"en": "Hold up. We stopped you for a reason.", "ar": "وقف… وقفناك لسبب."},
|
|
26
|
+
|
|
27
|
+
# Section markers (◉ prefix)
|
|
28
|
+
"scan": {"en": "scan", "ar": "فحص"},
|
|
29
|
+
"plan": {"en": "plan", "ar": "خطة"},
|
|
30
|
+
"apply": {"en": "apply", "ar": "تطبيق"},
|
|
31
|
+
"guard": {"en": "guard", "ar": "حماية"},
|
|
32
|
+
|
|
33
|
+
# Action markers
|
|
34
|
+
"created": {"en": "created", "ar": "تم"},
|
|
35
|
+
"updated": {"en": "updated", "ar": "تحديث"},
|
|
36
|
+
"skipped": {"en": "skipped", "ar": "تخطّي"},
|
|
37
|
+
|
|
38
|
+
# Status messages
|
|
39
|
+
"commit_guard_enabled": {"en": "commit guard enabled", "ar": "حماية الكوميت مفعّلة"},
|
|
40
|
+
"install_failed": {"en": "install failed", "ar": "فشل التثبيت"},
|
|
41
|
+
"not_initialized": {"en": "Not initialized", "ar": "غير مهيّأ"},
|
|
42
|
+
|
|
43
|
+
# Labels
|
|
44
|
+
"detected": {"en": "Detected", "ar": "اكتشف"},
|
|
45
|
+
"packs": {"en": "Packs", "ar": "حزم"},
|
|
46
|
+
"why": {"en": "why", "ar": "لماذا"},
|
|
47
|
+
"suggested": {"en": "suggested", "ar": "مقترح"},
|
|
48
|
+
"issues": {"en": "issue(s)", "ar": "مشكلة"},
|
|
49
|
+
"file_s": {"en": "file(s)", "ar": "ملف"},
|
|
50
|
+
|
|
51
|
+
# Hint prefixes
|
|
52
|
+
"fix": {"en": "Fix", "ar": "الحل"},
|
|
53
|
+
"run": {"en": "Run", "ar": "شغّل"},
|
|
54
|
+
"warning": {"en": "heads up", "ar": "تنبيه"},
|
|
55
|
+
|
|
56
|
+
# Scan modes
|
|
57
|
+
"mode": {"en": "mode", "ar": "الوضع"},
|
|
58
|
+
"mode_fast": {"en": "fast", "ar": "سريع"},
|
|
59
|
+
"mode_deep": {"en": "deep", "ar": "عميق"},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_current_lang: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def detect_lang() -> str:
|
|
67
|
+
"""Detect language from environment. Priority: BOOTSEC_LANG > LANG/LC_ALL > en."""
|
|
68
|
+
# Check explicit setting
|
|
69
|
+
bootsec_lang = os.environ.get("BOOTSEC_LANG", "").lower()
|
|
70
|
+
if bootsec_lang in (LANG_AR, LANG_EN):
|
|
71
|
+
return bootsec_lang
|
|
72
|
+
|
|
73
|
+
# Check system locale
|
|
74
|
+
for var in ("LANG", "LC_ALL", "LC_MESSAGES"):
|
|
75
|
+
val = os.environ.get(var, "").lower()
|
|
76
|
+
if val.startswith("ar"):
|
|
77
|
+
return LANG_AR
|
|
78
|
+
|
|
79
|
+
return LANG_EN
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_lang(lang: str) -> None:
|
|
83
|
+
"""Override the detected language."""
|
|
84
|
+
global _current_lang
|
|
85
|
+
if lang in (LANG_AR, LANG_EN, "auto"):
|
|
86
|
+
_current_lang = None if lang == "auto" else lang
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_lang() -> str:
|
|
90
|
+
"""Get current language."""
|
|
91
|
+
if _current_lang is not None:
|
|
92
|
+
return _current_lang
|
|
93
|
+
return detect_lang()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def t(key: str) -> str:
|
|
97
|
+
"""Translate a message key to current language."""
|
|
98
|
+
lang = get_lang()
|
|
99
|
+
msg = MESSAGES.get(key, {})
|
|
100
|
+
return msg.get(lang, msg.get("en", key))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_lang_arg(args: list[str]) -> list[str]:
|
|
104
|
+
"""Parse --lang flag from args and set language. Returns args without --lang."""
|
|
105
|
+
result = []
|
|
106
|
+
i = 0
|
|
107
|
+
while i < len(args):
|
|
108
|
+
if args[i] == "--lang" and i + 1 < len(args):
|
|
109
|
+
set_lang(args[i + 1].lower())
|
|
110
|
+
i += 2
|
|
111
|
+
else:
|
|
112
|
+
result.append(args[i])
|
|
113
|
+
i += 1
|
|
114
|
+
return result
|
bootsec/loader.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from importlib import resources
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_template(category: str, name: str) -> str:
|
|
5
|
+
try:
|
|
6
|
+
return resources.files("bootsec.templates").joinpath(category, name).read_text()
|
|
7
|
+
except FileNotFoundError:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
return resources.files("bootsec.templates").joinpath("common", name).read_text()
|
|
12
|
+
except FileNotFoundError:
|
|
13
|
+
return ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render(template: str, **variables: str) -> str:
|
|
17
|
+
result = template
|
|
18
|
+
for key, value in variables.items():
|
|
19
|
+
result = result.replace(f"{{{{{key}}}}}", str(value))
|
|
20
|
+
return result
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_pack_template(pack_name: str, name: str) -> str:
|
|
24
|
+
try:
|
|
25
|
+
return resources.files("bootsec.templates").joinpath("packs", pack_name, name).read_text()
|
|
26
|
+
except (FileNotFoundError, TypeError):
|
|
27
|
+
return ""
|