framework-py 0.1.0.dev0__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.
- framework/__init__.py +250 -0
- framework/__main__.py +89 -0
- framework/_assets/hooks/pre-commit +59 -0
- framework/_assets/init.sql +63 -0
- framework/_assets/schema/layer.v1.json +605 -0
- framework/_assets/schema/layers.v1.json +791 -0
- framework/_assets/schema/spec.v1.json +604 -0
- framework/_log.py +29 -0
- framework/_paths.py +45 -0
- framework/_secretservice.py +69 -0
- framework/_time.py +11 -0
- framework/_wincred.py +105 -0
- framework/_yaml.py +90 -0
- framework/ai.py +204 -0
- framework/bootstrap.py +206 -0
- framework/cache.py +335 -0
- framework/catalog.py +193 -0
- framework/cloud.py +899 -0
- framework/config_home.py +194 -0
- framework/connections.py +442 -0
- framework/console.py +41 -0
- framework/create.py +579 -0
- framework/data.py +519 -0
- framework/db.py +222 -0
- framework/dotenv.py +79 -0
- framework/errors.py +39 -0
- framework/global_config.py +238 -0
- framework/hooks.py +364 -0
- framework/integrity.py +22 -0
- framework/notebooks.py +384 -0
- framework/outputs.py +324 -0
- framework/packages.py +276 -0
- framework/project.py +52 -0
- framework/py.typed +0 -0
- framework/quarto.py +163 -0
- framework/scaffold.py +269 -0
- framework/scratch.py +108 -0
- framework/security.py +646 -0
- framework/settings.py +439 -0
- framework/spec/__init__.py +0 -0
- framework/spec/check.py +211 -0
- framework/spec/merge.py +195 -0
- framework/spec/resolve.py +254 -0
- framework/storage.py +516 -0
- framework/templates/defaults.yml +144 -0
- framework/templates/gitignore/course +120 -0
- framework/templates/gitignore/presentation +114 -0
- framework/templates/gitignore/project +108 -0
- framework/templates/gitignore/sensitive +139 -0
- framework/templates/skills/framework-data/SKILL.md +47 -0
- framework/templates/skills/framework-outputs/SKILL.md +68 -0
- framework/templates/skills/framework-packages/SKILL.md +33 -0
- framework/templates/skills/framework-sensitive-data/SKILL.md +38 -0
- framework/templates/skills/framework-workflow/SKILL.md +58 -0
- framework/templates/stubs/notebook-default.ipynb +34 -0
- framework/templates/stubs/notebook-default.qmd +43 -0
- framework/templates/stubs/notebook-minimal.qmd +15 -0
- framework/templates/stubs/notebook-revealjs.qmd +34 -0
- framework/templates/stubs/script-default.py +18 -0
- framework_py-0.1.0.dev0.dist-info/METADATA +111 -0
- framework_py-0.1.0.dev0.dist-info/RECORD +63 -0
- framework_py-0.1.0.dev0.dist-info/WHEEL +4 -0
- framework_py-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
framework/security.py
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
"""``git_security_audit()``: checks that private data stays out of git.
|
|
2
|
+
|
|
3
|
+
The checks match R's ``git_security_audit()``:
|
|
4
|
+
|
|
5
|
+
- gitignore_coverage: private folders and the data files in them are ignored.
|
|
6
|
+
- private_data_exposure: git tracks no file in a private folder.
|
|
7
|
+
- git_history: no commit added a private data file.
|
|
8
|
+
- orphaned_files: data files sit inside the configured folders.
|
|
9
|
+
- secrets_exposure: git tracks no ``.env`` file.
|
|
10
|
+
|
|
11
|
+
Where git is available, ``git check-ignore`` decides what is ignored. Without
|
|
12
|
+
git, a port of R's ``.gitignore`` matcher does.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
from framework import db
|
|
29
|
+
from framework._log import inform, show_messages
|
|
30
|
+
from framework.errors import FrameworkError
|
|
31
|
+
from framework.project import find_settings_file
|
|
32
|
+
from framework.settings import settings_read
|
|
33
|
+
|
|
34
|
+
DATA_EXTENSIONS = (
|
|
35
|
+
"csv", "rds", "tsv", "txt", "dat", "xlsx", "xls", "sqlite", "db", "dta", "sav",
|
|
36
|
+
"zsav", "por", "sas7bdat", "sas7bcat", "xpt", "parquet", "feather", "arrow",
|
|
37
|
+
"json", "xml", "h5", "hdf5",
|
|
38
|
+
) # fmt: skip
|
|
39
|
+
|
|
40
|
+
CHECKS = (
|
|
41
|
+
"gitignore_coverage",
|
|
42
|
+
"private_data_exposure",
|
|
43
|
+
"git_history",
|
|
44
|
+
"orphaned_files",
|
|
45
|
+
"secrets_exposure",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
_EXCLUDED_DIRS = (
|
|
49
|
+
".git", ".Rproj.user", "renv", "packrat", ".framework_cache", ".quarto", "_cache",
|
|
50
|
+
"_files", ".venv", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
|
|
51
|
+
# Framework's and editors' own configuration, which is JSON but not data.
|
|
52
|
+
".framework", ".claude", ".github", ".vscode", ".idea", ".positron", "node_modules",
|
|
53
|
+
) # fmt: skip
|
|
54
|
+
_EXCLUDED_FILES = {"framework.db", ".framework_renv_enabled", ".framework_scaffolded", ".initiated"}
|
|
55
|
+
_PRIVATE_SEGMENT = re.compile(
|
|
56
|
+
r"(^|/)[^/]*(private|confidential|sensitive)[^/]*(/|$)", re.IGNORECASE
|
|
57
|
+
)
|
|
58
|
+
_SECRETS_FILE = re.compile(r"^\.env(\..+)?$")
|
|
59
|
+
_SECRETS_EXAMPLE = re.compile(r"\.(example|template|sample)$")
|
|
60
|
+
_HISTORY_ACTIONS = {"A": "added", "M": "modified", "D": "deleted"}
|
|
61
|
+
_AUTO_FIX_HEADER = "# Framework Security Audit - Auto-generated"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class AuditResult:
|
|
66
|
+
"""What ``git_security_audit()`` found.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
summary: One row per check, with columns check, status (pass,
|
|
70
|
+
warning, fail, or skipped), and count.
|
|
71
|
+
findings: A DataFrame of findings per check.
|
|
72
|
+
recommendations: What to do about the findings.
|
|
73
|
+
metadata: When the audit ran, the folders it checked, and whether git
|
|
74
|
+
was available.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
summary: pd.DataFrame
|
|
78
|
+
findings: dict[str, pd.DataFrame]
|
|
79
|
+
recommendations: list[str] = field(default_factory=list)
|
|
80
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def status(self) -> str:
|
|
84
|
+
"""The overall result: fail, warning, or pass."""
|
|
85
|
+
statuses = set(self.summary["status"])
|
|
86
|
+
return "fail" if "fail" in statuses else "warning" if "warning" in statuses else "pass"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def passed(self) -> bool:
|
|
90
|
+
"""Whether no check failed. Warnings still pass."""
|
|
91
|
+
return self.status != "fail"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Git ------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _git(root: Path, *args: str, input: str | None = None) -> subprocess.CompletedProcess[str]:
|
|
98
|
+
return subprocess.run(
|
|
99
|
+
["git", "-C", str(root), *args],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
input=input,
|
|
103
|
+
check=False,
|
|
104
|
+
encoding="utf-8",
|
|
105
|
+
errors="replace",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _git_available(root: Path) -> bool:
|
|
110
|
+
if shutil.which("git") is None:
|
|
111
|
+
return False
|
|
112
|
+
return _git(root, "rev-parse", "--show-toplevel").returncode == 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _tracked_files(root: Path) -> list[str]:
|
|
116
|
+
result = _git(root, "ls-files", "-z")
|
|
117
|
+
return [name for name in result.stdout.split("\0") if name] if result.returncode == 0 else []
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Ignore rules ---------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _gitignore_regex(pattern: str) -> str:
|
|
124
|
+
"""R's translation of one ``.gitignore`` pattern to an anchored regex."""
|
|
125
|
+
directory_only = pattern.endswith("/")
|
|
126
|
+
pattern = pattern.removeprefix("/")
|
|
127
|
+
pattern = pattern.replace(".", r"\.")
|
|
128
|
+
pattern = pattern.replace("**/", "\x01")
|
|
129
|
+
pattern = re.sub(r"/\*\*$", "\x02", pattern)
|
|
130
|
+
pattern = pattern.replace("**", "\x03")
|
|
131
|
+
pattern = pattern.replace("*", "[^/]*")
|
|
132
|
+
pattern = pattern.replace("\x01", "(.*/)?").replace("\x02", "/.*").replace("\x03", ".*")
|
|
133
|
+
if directory_only:
|
|
134
|
+
pattern = pattern.removesuffix("/") + "(/.*)?"
|
|
135
|
+
return pattern
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _matches_gitignore(path: str, lines: list[str]) -> bool:
|
|
139
|
+
"""Whether ``.gitignore`` lines ignore ``path``, as R checks without git."""
|
|
140
|
+
path = path.removeprefix("./")
|
|
141
|
+
ignored = False
|
|
142
|
+
for line in lines:
|
|
143
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
144
|
+
continue
|
|
145
|
+
negated = line.startswith("!")
|
|
146
|
+
regex = _gitignore_regex(line[1:] if negated else line)
|
|
147
|
+
if re.fullmatch(regex, path):
|
|
148
|
+
ignored = not negated
|
|
149
|
+
return ignored
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class _IgnoreRules:
|
|
153
|
+
"""Answers whether paths are ignored, through git when it is available."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, root: Path, use_git: bool) -> None:
|
|
156
|
+
self.root = root
|
|
157
|
+
self.use_git = use_git
|
|
158
|
+
gitignore = root / ".gitignore"
|
|
159
|
+
self.lines = (
|
|
160
|
+
gitignore.read_text(encoding="utf-8").splitlines() if gitignore.exists() else []
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def ignored(self, paths: list[str]) -> set[str]:
|
|
164
|
+
if not paths:
|
|
165
|
+
return set()
|
|
166
|
+
if not self.use_git:
|
|
167
|
+
return {path for path in paths if _matches_gitignore(path, self.lines)}
|
|
168
|
+
# --no-index checks the rules even for tracked files. -z separates paths
|
|
169
|
+
# with NUL, since text pipes on Windows turn "\n" into "\r\n".
|
|
170
|
+
result = _git(
|
|
171
|
+
self.root, "check-ignore", "--no-index", "--stdin", "-z", input="\0".join(paths)
|
|
172
|
+
)
|
|
173
|
+
return {path for path in result.stdout.split("\0") if path}
|
|
174
|
+
|
|
175
|
+
def folder_ignored(self, folder: str) -> bool:
|
|
176
|
+
"""A folder counts as ignored when it, or anything inside it, would be."""
|
|
177
|
+
probe = f"{folder}/.framework-audit-probe"
|
|
178
|
+
return bool(self.ignored([folder, f"{folder}/", probe]))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# Folders --------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _data_directories(config: dict[str, Any], root: Path) -> dict[str, str]:
|
|
185
|
+
"""The folders the audit watches: the settings' data folders, then R's fallbacks."""
|
|
186
|
+
folders: dict[str, str] = {}
|
|
187
|
+
directories = config.get("directories")
|
|
188
|
+
for key, value in (directories if isinstance(directories, dict) else {}).items():
|
|
189
|
+
if not isinstance(value, str) or not value:
|
|
190
|
+
continue
|
|
191
|
+
if re.match(r"^(inputs|outputs)_", key) or key in ("cache", "scratch", "logs"):
|
|
192
|
+
folders[key] = value.removeprefix("./").rstrip("/")
|
|
193
|
+
|
|
194
|
+
def fallback(key: str, path: str) -> None:
|
|
195
|
+
if key not in folders and (root / path).is_dir():
|
|
196
|
+
folders[key] = path
|
|
197
|
+
|
|
198
|
+
for name in ("raw", "intermediate", "final"):
|
|
199
|
+
fallback(f"inputs_{name}", f"inputs/{name}")
|
|
200
|
+
fallback("docs", "docs")
|
|
201
|
+
fallback("outputs_private", "outputs/private")
|
|
202
|
+
for name in ("tables", "figures", "models", "notebooks", "docs", "final", "cache", "scratch"):
|
|
203
|
+
fallback(f"outputs_{name}", f"outputs/private/{name}")
|
|
204
|
+
fallback("outputs_public", "outputs/public")
|
|
205
|
+
for name in ("tables", "figures", "models", "notebooks", "docs", "final"):
|
|
206
|
+
fallback(f"outputs_{name}_public", f"outputs/public/{name}")
|
|
207
|
+
return folders
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _private_directories(folders: dict[str, str]) -> dict[str, str]:
|
|
211
|
+
"""The folders whose contents must not be committed."""
|
|
212
|
+
return {
|
|
213
|
+
key: path
|
|
214
|
+
for key, path in folders.items()
|
|
215
|
+
if "_public" not in key
|
|
216
|
+
and (
|
|
217
|
+
key.startswith(("inputs_", "outputs_"))
|
|
218
|
+
or "private" in key
|
|
219
|
+
or key in ("cache", "scratch", "logs")
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _is_data_file(path: str, extensions: tuple[str, ...]) -> bool:
|
|
225
|
+
return Path(path).suffix.lower().removeprefix(".") in extensions
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _inside(path: str, folder: str) -> bool:
|
|
229
|
+
return path == folder or path.startswith(folder.rstrip("/") + "/")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _files_under(root: Path, folder: str) -> list[str]:
|
|
233
|
+
base = root / folder
|
|
234
|
+
return sorted(path.relative_to(root).as_posix() for path in base.rglob("*") if path.is_file())
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# Checks ---------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _check_gitignore(
|
|
241
|
+
root: Path, private: dict[str, str], rules: _IgnoreRules, extensions: tuple[str, ...]
|
|
242
|
+
) -> tuple[pd.DataFrame, list[str]]:
|
|
243
|
+
rows: list[dict[str, str]] = []
|
|
244
|
+
if not (root / ".gitignore").exists():
|
|
245
|
+
return _frame(rows, ["file", "directory", "severity", "reason"]), [
|
|
246
|
+
"Create a .gitignore file to protect sensitive data"
|
|
247
|
+
]
|
|
248
|
+
for key, folder in private.items():
|
|
249
|
+
if not (root / folder).is_dir():
|
|
250
|
+
continue
|
|
251
|
+
if not rules.folder_ignored(folder):
|
|
252
|
+
rows.append(
|
|
253
|
+
{
|
|
254
|
+
"file": folder,
|
|
255
|
+
"directory": key,
|
|
256
|
+
"severity": "critical",
|
|
257
|
+
"reason": "Private data directory not in .gitignore",
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
data_files = [f for f in _files_under(root, folder) if _is_data_file(f, extensions)]
|
|
261
|
+
ignored = rules.ignored(data_files)
|
|
262
|
+
rows.extend(
|
|
263
|
+
{
|
|
264
|
+
"file": name,
|
|
265
|
+
"directory": key,
|
|
266
|
+
"severity": "critical",
|
|
267
|
+
"reason": "Private data file not in .gitignore",
|
|
268
|
+
}
|
|
269
|
+
for name in data_files
|
|
270
|
+
if name not in ignored
|
|
271
|
+
)
|
|
272
|
+
advice = (
|
|
273
|
+
[
|
|
274
|
+
"Add private data directories to .gitignore",
|
|
275
|
+
"Review all private data files for git tracking",
|
|
276
|
+
]
|
|
277
|
+
if rows
|
|
278
|
+
else []
|
|
279
|
+
)
|
|
280
|
+
return _frame(rows, ["file", "directory", "severity", "reason"]), advice
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _check_exposure(
|
|
284
|
+
root: Path, private: dict[str, str], tracked: list[str], extensions: tuple[str, ...]
|
|
285
|
+
) -> tuple[pd.DataFrame, list[str]]:
|
|
286
|
+
rows = [
|
|
287
|
+
{"file": name, "directory": key, "git_status": "tracked"}
|
|
288
|
+
for key, folder in private.items()
|
|
289
|
+
if (root / folder).is_dir()
|
|
290
|
+
for name in tracked
|
|
291
|
+
if _inside(name, folder)
|
|
292
|
+
]
|
|
293
|
+
# Also data files in any folder named private, confidential, or sensitive,
|
|
294
|
+
# such as inputs/private itself, which no setting names.
|
|
295
|
+
reported = {row["file"] for row in rows}
|
|
296
|
+
rows += [
|
|
297
|
+
{"file": name, "directory": str(Path(name).parent.as_posix()), "git_status": "tracked"}
|
|
298
|
+
for name in tracked
|
|
299
|
+
if name not in reported
|
|
300
|
+
and _is_data_file(name, extensions)
|
|
301
|
+
and _PRIVATE_SEGMENT.search(str(Path(name).parent.as_posix()))
|
|
302
|
+
]
|
|
303
|
+
advice = (
|
|
304
|
+
[
|
|
305
|
+
"CRITICAL: Private data files are tracked by git!",
|
|
306
|
+
"Run: git rm --cached <file> to untrack files",
|
|
307
|
+
"Add files to .gitignore before committing",
|
|
308
|
+
]
|
|
309
|
+
if rows
|
|
310
|
+
else []
|
|
311
|
+
)
|
|
312
|
+
return _frame(rows, ["file", "directory", "git_status"]), advice
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _check_secrets(tracked: list[str]) -> tuple[pd.DataFrame, list[str]]:
|
|
316
|
+
files = [
|
|
317
|
+
name
|
|
318
|
+
for name in tracked
|
|
319
|
+
if _SECRETS_FILE.match(Path(name).name) and not _SECRETS_EXAMPLE.search(name)
|
|
320
|
+
]
|
|
321
|
+
rows = [{"file": name, "git_status": "tracked"} for name in files]
|
|
322
|
+
advice = (
|
|
323
|
+
[
|
|
324
|
+
"CRITICAL: Secrets files are tracked by git!",
|
|
325
|
+
f"Run: git rm --cached {' '.join(files)}, then commit",
|
|
326
|
+
"Add .env to .gitignore",
|
|
327
|
+
"Rotate any passwords or tokens in those files (including FW_PROJECT_TOKEN): "
|
|
328
|
+
"they stay in git history",
|
|
329
|
+
]
|
|
330
|
+
if files
|
|
331
|
+
else []
|
|
332
|
+
)
|
|
333
|
+
return _frame(rows, ["file", "git_status"]), advice
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _check_history(
|
|
337
|
+
root: Path,
|
|
338
|
+
private: dict[str, str],
|
|
339
|
+
extensions: tuple[str, ...],
|
|
340
|
+
depth: int | str,
|
|
341
|
+
) -> tuple[pd.DataFrame, list[str]]:
|
|
342
|
+
args = ["log", "--all", "--pretty=format:%H|%ad|%s", "--date=iso", "--name-status"]
|
|
343
|
+
if isinstance(depth, int):
|
|
344
|
+
args.append(f"-{depth}")
|
|
345
|
+
elif depth == "shallow":
|
|
346
|
+
args.append("-100")
|
|
347
|
+
result = _git(root, *args)
|
|
348
|
+
rows: list[dict[str, str]] = []
|
|
349
|
+
commit = date = None
|
|
350
|
+
for line in result.stdout.splitlines() if result.returncode == 0 else []:
|
|
351
|
+
if re.match(r"^[0-9a-f]{40}\|", line):
|
|
352
|
+
sha, date, *_ = line.split("|")
|
|
353
|
+
commit = sha[:8]
|
|
354
|
+
continue
|
|
355
|
+
parts = line.split("\t")
|
|
356
|
+
if commit is None or len(parts) < 2 or parts[0] not in _HISTORY_ACTIONS:
|
|
357
|
+
continue
|
|
358
|
+
name = parts[1]
|
|
359
|
+
if not _is_data_file(name, extensions):
|
|
360
|
+
continue
|
|
361
|
+
in_private = any(_inside(name, folder) for folder in private.values())
|
|
362
|
+
if in_private or re.search("private|secret|confidential", name, re.IGNORECASE):
|
|
363
|
+
rows.append(
|
|
364
|
+
{
|
|
365
|
+
"commit": commit,
|
|
366
|
+
"file": name,
|
|
367
|
+
"action": _HISTORY_ACTIONS[parts[0]],
|
|
368
|
+
"date": date or "",
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
advice = (
|
|
372
|
+
[
|
|
373
|
+
"CRITICAL: Private data files found in git history!",
|
|
374
|
+
"Consider using git-filter-repo or BFG Repo-Cleaner to remove sensitive data",
|
|
375
|
+
"Consult: https://docs.github.com/en/authentication/keeping-your-account-and-data-"
|
|
376
|
+
"secure/removing-sensitive-data-from-a-repository",
|
|
377
|
+
]
|
|
378
|
+
if rows
|
|
379
|
+
else []
|
|
380
|
+
)
|
|
381
|
+
return _frame(rows, ["commit", "file", "action", "date"]), advice
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _check_orphans(
|
|
385
|
+
root: Path, folders: dict[str, str], extensions: tuple[str, ...]
|
|
386
|
+
) -> tuple[pd.DataFrame, list[str]]:
|
|
387
|
+
rows: list[dict[str, Any]] = []
|
|
388
|
+
configured = list(folders.values())
|
|
389
|
+
for path in _walk_files(root):
|
|
390
|
+
relative = path.relative_to(root).as_posix()
|
|
391
|
+
if relative in _EXCLUDED_FILES:
|
|
392
|
+
continue
|
|
393
|
+
if not _is_data_file(relative, extensions):
|
|
394
|
+
continue
|
|
395
|
+
if any(_inside(relative, folder) for folder in configured):
|
|
396
|
+
continue
|
|
397
|
+
info = path.stat()
|
|
398
|
+
rows.append(
|
|
399
|
+
{
|
|
400
|
+
"path": relative,
|
|
401
|
+
"size": info.st_size,
|
|
402
|
+
"modified": datetime.fromtimestamp(info.st_mtime).isoformat(
|
|
403
|
+
sep=" ", timespec="seconds"
|
|
404
|
+
),
|
|
405
|
+
"extension": path.suffix.removeprefix("."),
|
|
406
|
+
}
|
|
407
|
+
)
|
|
408
|
+
advice = (
|
|
409
|
+
[
|
|
410
|
+
f"Found {len(rows)} data file(s) outside configured directories",
|
|
411
|
+
"Move orphaned files to appropriate data directories",
|
|
412
|
+
"Add data file locations to settings.yml if they represent new data storage",
|
|
413
|
+
]
|
|
414
|
+
if rows
|
|
415
|
+
else []
|
|
416
|
+
)
|
|
417
|
+
return _frame(rows, ["path", "size", "modified", "extension"]), advice
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _walk_files(root: Path) -> list[Path]:
|
|
421
|
+
"""Every file under the root, skipping excluded top-level folders."""
|
|
422
|
+
found: list[Path] = []
|
|
423
|
+
for current, dirnames, filenames in os.walk(root):
|
|
424
|
+
if Path(current) == root:
|
|
425
|
+
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
|
426
|
+
dirnames[:] = [d for d in dirnames if d not in (".git", "__pycache__")]
|
|
427
|
+
found.extend(Path(current) / name for name in filenames)
|
|
428
|
+
return sorted(found)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _frame(rows: list[dict[str, Any]], columns: list[str]) -> pd.DataFrame:
|
|
432
|
+
return pd.DataFrame(rows, columns=columns)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# Auto-fix and reporting -----------------------------------------------------
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _auto_fix(root: Path, findings: dict[str, pd.DataFrame]) -> int:
|
|
439
|
+
"""Add the folders behind coverage and exposure findings to ``.gitignore``."""
|
|
440
|
+
folders = list(findings["gitignore_coverage"]["file"])
|
|
441
|
+
folders += [
|
|
442
|
+
str(Path(name).parent.as_posix()) for name in findings["private_data_exposure"]["file"]
|
|
443
|
+
]
|
|
444
|
+
folders = list(dict.fromkeys(f.strip("/") for f in folders if f.strip("/") not in ("", ".")))
|
|
445
|
+
if not folders:
|
|
446
|
+
return 0
|
|
447
|
+
path = root / ".gitignore"
|
|
448
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
449
|
+
existing = text.splitlines()
|
|
450
|
+
added: list[str] = []
|
|
451
|
+
if not any(line.strip() == _AUTO_FIX_HEADER for line in existing):
|
|
452
|
+
added += ["", _AUTO_FIX_HEADER]
|
|
453
|
+
for folder in folders:
|
|
454
|
+
for entry in (f"{folder}/", f"{folder}/**"):
|
|
455
|
+
if entry not in existing and entry not in added:
|
|
456
|
+
added.append(entry)
|
|
457
|
+
if not any(line and not line.startswith("#") for line in added):
|
|
458
|
+
return 0
|
|
459
|
+
if text and not text.endswith("\n"):
|
|
460
|
+
text += "\n"
|
|
461
|
+
path.write_text(text + "\n".join(added) + "\n", encoding="utf-8")
|
|
462
|
+
inform(f"Added {len(folders)} director{'y' if len(folders) == 1 else 'ies'} to .gitignore")
|
|
463
|
+
return len(folders)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
_ICONS = {"pass": "✓ PASS", "warning": "⚠ WARNING", "fail": "✗ FAIL", "skipped": "- SKIPPED"}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _report(result: AuditResult) -> str:
|
|
470
|
+
lines = ["", "=== Security Audit Summary ===", ""]
|
|
471
|
+
for row in result.summary.itertuples():
|
|
472
|
+
label = str(row.check).replace("_", " ")
|
|
473
|
+
lines.append(f"{_ICONS[str(row.status)]}: {label} ({row.count} issues)")
|
|
474
|
+
|
|
475
|
+
sections = {
|
|
476
|
+
"gitignore_coverage": "Gitignore Coverage Issues",
|
|
477
|
+
"private_data_exposure": "Private Data Exposure (Git Tracked)",
|
|
478
|
+
"git_history": "Git History Leaks",
|
|
479
|
+
"secrets_exposure": "Tracked Secrets Files",
|
|
480
|
+
"orphaned_files": "Orphaned Data Files",
|
|
481
|
+
}
|
|
482
|
+
for check, title in sections.items():
|
|
483
|
+
frame = result.findings[check]
|
|
484
|
+
if frame.empty:
|
|
485
|
+
continue
|
|
486
|
+
lines += ["", f"--- {title} ---"]
|
|
487
|
+
for number, row in enumerate(frame.head(10).itertuples(), start=1):
|
|
488
|
+
if check == "gitignore_coverage":
|
|
489
|
+
lines.append(f" {number}. [{str(row.severity).upper()}] {row.file}")
|
|
490
|
+
lines.append(f" Directory: {row.directory}")
|
|
491
|
+
lines.append(f" Reason: {row.reason}")
|
|
492
|
+
elif check == "private_data_exposure":
|
|
493
|
+
lines.append(f" {number}. {row.file} (tracked in git, {row.directory})")
|
|
494
|
+
elif check == "git_history":
|
|
495
|
+
lines.append(
|
|
496
|
+
f" {number}. {row.file} ({row.action} in commit {row.commit}, {row.date})"
|
|
497
|
+
)
|
|
498
|
+
elif check == "secrets_exposure":
|
|
499
|
+
lines.append(f" {number}. {row.file} (tracked in git)")
|
|
500
|
+
else:
|
|
501
|
+
lines.append(f" {number}. {row.path} ({round(float(str(row.size)) / 1024, 1)} KB)")
|
|
502
|
+
if len(frame) > 10:
|
|
503
|
+
lines.append(f" ... and {len(frame) - 10} more")
|
|
504
|
+
|
|
505
|
+
if result.recommendations:
|
|
506
|
+
lines += ["", "=== Recommendations ===", ""]
|
|
507
|
+
lines += [f" {n}. {text}" for n, text in enumerate(result.recommendations, start=1)]
|
|
508
|
+
lines.append("")
|
|
509
|
+
lines.append(
|
|
510
|
+
{
|
|
511
|
+
"fail": "✗ AUDIT FAILED - Critical security issues found",
|
|
512
|
+
"warning": "⚠ AUDIT PASSED WITH WARNINGS - Review findings",
|
|
513
|
+
"pass": "✓ AUDIT PASSED - No security issues found",
|
|
514
|
+
}[result.status]
|
|
515
|
+
)
|
|
516
|
+
return "\n".join(lines)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _save(root: Path, result: AuditResult) -> None:
|
|
520
|
+
if not db.db_path(root).exists():
|
|
521
|
+
return
|
|
522
|
+
db.set_meta("last_git_security_audit", result.metadata["timestamp"], root)
|
|
523
|
+
db.set_meta("last_audit_status", result.status, root)
|
|
524
|
+
db.set_meta("last_audit_issues", str(int(result.summary["count"].sum())), root)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# Public API -----------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def git_security_audit(
|
|
531
|
+
settings_file: str | Path | None = None,
|
|
532
|
+
*,
|
|
533
|
+
check_git_history: bool = True,
|
|
534
|
+
history_depth: int | str = "all",
|
|
535
|
+
auto_fix: bool = False,
|
|
536
|
+
verbose: bool = True,
|
|
537
|
+
extensions: tuple[str, ...] | list[str] = DATA_EXTENSIONS,
|
|
538
|
+
) -> AuditResult:
|
|
539
|
+
"""Check that private data and secrets stay out of git.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
settings_file: The project's settings file. Found from the current
|
|
543
|
+
folder when omitted.
|
|
544
|
+
check_git_history: Scan past commits for private data files.
|
|
545
|
+
history_depth: ``"all"``, ``"shallow"`` (the last 100 commits), or a
|
|
546
|
+
number of commits.
|
|
547
|
+
auto_fix: Add private folders that are not ignored to ``.gitignore``.
|
|
548
|
+
Files git already tracks stay tracked: run ``git rm --cached``.
|
|
549
|
+
verbose: Print the report.
|
|
550
|
+
extensions: File extensions that count as data files.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
An ``AuditResult``. ``result.passed`` is False when a check failed.
|
|
554
|
+
The result is also recorded in ``framework.db`` when it exists.
|
|
555
|
+
"""
|
|
556
|
+
if isinstance(history_depth, bool) or (isinstance(history_depth, int) and history_depth < 1):
|
|
557
|
+
raise ValueError("history_depth must be 'all', 'shallow', or a positive number of commits")
|
|
558
|
+
if isinstance(history_depth, str) and history_depth not in ("all", "shallow"):
|
|
559
|
+
raise ValueError("history_depth must be 'all', 'shallow', or a positive number of commits")
|
|
560
|
+
if verbose:
|
|
561
|
+
show_messages()
|
|
562
|
+
path = Path(settings_file) if settings_file is not None else find_settings_file()
|
|
563
|
+
if path is None or not path.is_file():
|
|
564
|
+
raise FrameworkError("Settings file not found (expected settings.yml or config.yml)")
|
|
565
|
+
root = path.resolve().parent
|
|
566
|
+
config = settings_read(path)
|
|
567
|
+
exts = tuple(e.lower().removeprefix(".") for e in extensions)
|
|
568
|
+
|
|
569
|
+
use_git = _git_available(root)
|
|
570
|
+
if not use_git and verbose:
|
|
571
|
+
inform("Git not available. Skipping git-specific checks.")
|
|
572
|
+
folders = _data_directories(config, root)
|
|
573
|
+
private = _private_directories(folders)
|
|
574
|
+
tracked = _tracked_files(root) if use_git else []
|
|
575
|
+
rules = _IgnoreRules(root, use_git)
|
|
576
|
+
|
|
577
|
+
findings: dict[str, pd.DataFrame] = {}
|
|
578
|
+
recommendations: list[str] = []
|
|
579
|
+
|
|
580
|
+
def record(check: str, outcome: tuple[pd.DataFrame, list[str]]) -> None:
|
|
581
|
+
findings[check] = outcome[0]
|
|
582
|
+
recommendations.extend(outcome[1])
|
|
583
|
+
|
|
584
|
+
record("gitignore_coverage", _check_gitignore(root, private, rules, exts))
|
|
585
|
+
record("private_data_exposure", _check_exposure(root, private, tracked, exts))
|
|
586
|
+
if check_git_history and use_git:
|
|
587
|
+
record("git_history", _check_history(root, private, exts, history_depth))
|
|
588
|
+
else:
|
|
589
|
+
findings["git_history"] = _frame([], ["commit", "file", "action", "date"])
|
|
590
|
+
record("secrets_exposure", _check_secrets(tracked))
|
|
591
|
+
record("orphaned_files", _check_orphans(root, folders, exts))
|
|
592
|
+
|
|
593
|
+
coverage = findings["gitignore_coverage"]
|
|
594
|
+
statuses = {
|
|
595
|
+
"gitignore_coverage": "pass"
|
|
596
|
+
if coverage.empty
|
|
597
|
+
else "fail"
|
|
598
|
+
if (coverage["severity"] == "critical").any()
|
|
599
|
+
else "warning",
|
|
600
|
+
"private_data_exposure": "fail" if len(findings["private_data_exposure"]) else "pass",
|
|
601
|
+
"git_history": "skipped"
|
|
602
|
+
if not (check_git_history and use_git)
|
|
603
|
+
else "fail"
|
|
604
|
+
if len(findings["git_history"])
|
|
605
|
+
else "pass",
|
|
606
|
+
"orphaned_files": "warning" if len(findings["orphaned_files"]) else "pass",
|
|
607
|
+
"secrets_exposure": "skipped"
|
|
608
|
+
if not use_git
|
|
609
|
+
else "fail"
|
|
610
|
+
if len(findings["secrets_exposure"])
|
|
611
|
+
else "pass",
|
|
612
|
+
}
|
|
613
|
+
summary = pd.DataFrame(
|
|
614
|
+
{
|
|
615
|
+
"check": list(CHECKS),
|
|
616
|
+
"status": [statuses[c] for c in CHECKS],
|
|
617
|
+
"count": [len(findings[c]) for c in CHECKS],
|
|
618
|
+
}
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
exposed = len(coverage) or len(findings["private_data_exposure"])
|
|
622
|
+
if auto_fix and exposed and _auto_fix(root, findings):
|
|
623
|
+
recommendations.append("Run git status to review .gitignore changes")
|
|
624
|
+
|
|
625
|
+
from framework import __version__
|
|
626
|
+
|
|
627
|
+
result = AuditResult(
|
|
628
|
+
summary=summary,
|
|
629
|
+
findings=findings,
|
|
630
|
+
recommendations=list(dict.fromkeys(recommendations)),
|
|
631
|
+
metadata={
|
|
632
|
+
"timestamp": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
|
633
|
+
"framework_version": __version__,
|
|
634
|
+
"config_directories": folders,
|
|
635
|
+
"git_available": use_git,
|
|
636
|
+
"auto_fix_applied": auto_fix,
|
|
637
|
+
},
|
|
638
|
+
)
|
|
639
|
+
if verbose:
|
|
640
|
+
print(_report(result))
|
|
641
|
+
try:
|
|
642
|
+
_save(root, result)
|
|
643
|
+
except Exception: # Recording the audit is optional, as in R.
|
|
644
|
+
if verbose:
|
|
645
|
+
inform("Note: Could not save audit to framework database")
|
|
646
|
+
return result
|