bmsdna-devtools 0.26.0__tar.gz → 0.27.0__tar.gz
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.
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/PKG-INFO +6 -2
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/README.md +5 -1
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/cli.py +8 -1
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/find_injection.py +89 -31
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_find_injection.py +62 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/uv.lock +1 -1
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.gitignore +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/.python-version +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_sql.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/justfile +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint_sql.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.26.0 → bmsdna_devtools-0.27.0}/tests/test_worktree_clean.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.27.0
|
|
4
4
|
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
@@ -607,7 +607,11 @@ code 0 unless `--strict`).
|
|
|
607
607
|
`staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
|
|
608
608
|
`'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
|
|
609
609
|
|
|
610
|
-
|
|
610
|
+
Skipped automatically: test files, generated code, `*.min.js` files, the usual build/vendor directories (`node_modules`, `.venv`, `dist`, ...) and,
|
|
611
|
+
inside a git repo, anything ignored by `.gitignore` (pass `--no-gitignore` to scan it anyway).
|
|
612
|
+
Add more with `--exclude` (repeatable; a directory name at any depth, a root-relative path, or a glob
|
|
613
|
+
such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproject.toml`.
|
|
614
|
+
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
|
|
611
615
|
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
612
616
|
|
|
613
617
|
## Releasing
|
|
@@ -594,7 +594,11 @@ code 0 unless `--strict`).
|
|
|
594
594
|
`staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
|
|
595
595
|
`'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
|
|
596
596
|
|
|
597
|
-
|
|
597
|
+
Skipped automatically: test files, generated code, `*.min.js` files, the usual build/vendor directories (`node_modules`, `.venv`, `dist`, ...) and,
|
|
598
|
+
inside a git repo, anything ignored by `.gitignore` (pass `--no-gitignore` to scan it anyway).
|
|
599
|
+
Add more with `--exclude` (repeatable; a directory name at any depth, a root-relative path, or a glob
|
|
600
|
+
such as `'assets/**/*.js'`) or `[tool.bdt.lint] exclude_dirs = [...]` in `pyproject.toml`.
|
|
601
|
+
Identical findings on the same line are reported once. Silence a confirmed-safe finding with
|
|
598
602
|
`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
|
|
599
603
|
|
|
600
604
|
## Releasing
|
|
@@ -933,13 +933,20 @@ def find_injection_cmd(
|
|
|
933
933
|
diff: bool = typer.Option(False, "--diff", help="Scan only files changed on this branch vs --base, plus uncommitted and untracked files."),
|
|
934
934
|
base: str = typer.Option(None, "--base", help="Branch/ref to diff against with --diff (default: origin's default branch)."),
|
|
935
935
|
strict: bool = typer.Option(False, "--strict", help="Also exit 1 when there are items to review (default: only definite errors fail)."),
|
|
936
|
+
exclude: list[str] = typer.Option(
|
|
937
|
+
None,
|
|
938
|
+
"--exclude",
|
|
939
|
+
help="Directory name (any depth), root-relative path, or glob to skip (repeatable), e.g. --exclude vendor --exclude 'assets/**/*.js'. "
|
|
940
|
+
"Also configurable via [tool.bdt.lint] exclude_dirs in pyproject.toml.",
|
|
941
|
+
),
|
|
942
|
+
no_gitignore: bool = typer.Option(False, "--no-gitignore", help="Also scan files ignored by .gitignore (default: they are skipped)."),
|
|
936
943
|
) -> None:
|
|
937
944
|
"""Find injection risks (bmsuisse/devtools#54) in backend and frontend code: SQL built from f-strings/
|
|
938
945
|
concatenation or from an unverifiable function call, eval/exec/shell/unsafe deserialization in Python,
|
|
939
946
|
innerHTML/dangerouslySetInnerHTML/eval/new Function/un-sandboxed iframes in the frontend, and a missing
|
|
940
947
|
or weakened Content-Security-Policy. Definite problems are errors; the rest are listed for a human/AI to verify.
|
|
941
948
|
"""
|
|
942
|
-
result = find_injection_mod.run(paths or [], diff=diff, base=base)
|
|
949
|
+
result = find_injection_mod.run(paths or [], diff=diff, base=base, exclude=exclude or [], respect_gitignore=not no_gitignore)
|
|
943
950
|
raise typer.Exit(find_injection_mod.print_report(result, strict=strict))
|
|
944
951
|
|
|
945
952
|
|
|
@@ -14,11 +14,13 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import os
|
|
16
16
|
import re
|
|
17
|
+
from fnmatch import fnmatch
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
from dataclasses import dataclass
|
|
20
21
|
from pathlib import Path
|
|
21
22
|
|
|
23
|
+
from .bdt_config import load_bdt_table
|
|
22
24
|
from .injection_frontend import (
|
|
23
25
|
FRONTEND_SUFFIXES,
|
|
24
26
|
check_frontend_file,
|
|
@@ -165,21 +167,79 @@ def _scan_python(path: Path) -> list[Finding]:
|
|
|
165
167
|
return sql + check_python_sinks(path)
|
|
166
168
|
|
|
167
169
|
|
|
168
|
-
|
|
170
|
+
_GLOB_CHARS = frozenset("*?[")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class _ScanFilter:
|
|
174
|
+
"""Which files are in scope: not under an excluded directory name, not matching an `--exclude`
|
|
175
|
+
path/glob (relative to the repo root), and -- in a git repo, unless disabled -- not gitignored
|
|
176
|
+
(tracked files always count, like git itself)."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, root: Path, exclude: list[str], respect_gitignore: bool) -> None:
|
|
179
|
+
self.root = root
|
|
180
|
+
self.dir_names = _DEFAULT_EXCLUDE_DIR_NAMES | {".next", "coverage", "storybook-static"}
|
|
181
|
+
self._patterns: list[str] = []
|
|
182
|
+
for entry in exclude:
|
|
183
|
+
entry = entry.replace("\\", "/").strip().strip("/")
|
|
184
|
+
if entry.startswith("./"):
|
|
185
|
+
entry = entry[2:]
|
|
186
|
+
if not entry:
|
|
187
|
+
continue
|
|
188
|
+
if "/" not in entry and not _GLOB_CHARS & set(entry):
|
|
189
|
+
self.dir_names = self.dir_names | {entry}
|
|
190
|
+
else:
|
|
191
|
+
self._patterns.append(entry)
|
|
192
|
+
self._visible = _git_visible_files(root) if respect_gitignore else None
|
|
193
|
+
|
|
194
|
+
def allows(self, path: Path) -> bool:
|
|
195
|
+
resolved = path.resolve()
|
|
196
|
+
try:
|
|
197
|
+
rel = resolved.relative_to(self.root)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return True
|
|
200
|
+
if any(part in self.dir_names for part in rel.parts[:-1]):
|
|
201
|
+
return False
|
|
202
|
+
posix = rel.as_posix()
|
|
203
|
+
if any(fnmatch(posix, pat) or fnmatch(posix, pat + "/*") for pat in self._patterns):
|
|
204
|
+
return False
|
|
205
|
+
return self._visible is None or resolved in self._visible
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _git_visible_files(root: Path) -> set[Path] | None:
|
|
209
|
+
"""Tracked plus untracked-but-not-ignored files under `root`, or None outside a git repo."""
|
|
210
|
+
try:
|
|
211
|
+
out = subprocess.run(
|
|
212
|
+
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
213
|
+
cwd=root,
|
|
214
|
+
capture_output=True,
|
|
215
|
+
encoding="utf-8",
|
|
216
|
+
)
|
|
217
|
+
except FileNotFoundError:
|
|
218
|
+
return None
|
|
219
|
+
if out.returncode != 0:
|
|
220
|
+
return None
|
|
221
|
+
return {(root / name).resolve() for name in out.stdout.split("\0") if name}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _is_minified(path: Path) -> bool:
|
|
225
|
+
return path.name.endswith(".min.js")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _walk_repo_text_files(root: Path, scope: _ScanFilter):
|
|
169
229
|
for dirpath, dirnames, filenames in os.walk(root):
|
|
170
|
-
dirnames[:] = [d for d in dirnames if d not in
|
|
230
|
+
dirnames[:] = [d for d in dirnames if d not in scope.dir_names]
|
|
171
231
|
for name in filenames:
|
|
172
232
|
if name in _SKIP_NAMES or name.endswith(".min.js") or not (name in _CONFIG_NAMES or name.endswith(_CONFIG_SUFFIXES)):
|
|
173
233
|
continue
|
|
174
234
|
candidate = Path(dirpath, name)
|
|
175
235
|
try:
|
|
176
|
-
if candidate.stat().st_size <= _MAX_CSP_FILE_BYTES:
|
|
236
|
+
if candidate.stat().st_size <= _MAX_CSP_FILE_BYTES and scope.allows(candidate):
|
|
177
237
|
yield candidate
|
|
178
238
|
except OSError:
|
|
179
239
|
continue
|
|
180
240
|
|
|
181
241
|
|
|
182
|
-
def check_csp(root: Path, scanned: list[Path],
|
|
242
|
+
def check_csp(root: Path, scanned: list[Path], scope: _ScanFilter, only: set[Path] | None = None) -> list[Finding]:
|
|
183
243
|
"""Warns when the project serves a web UI/API (frontend files scanned, or a Python web framework
|
|
184
244
|
imported) but no file anywhere under `root` mentions a Content-Security-Policy. Always searches the
|
|
185
245
|
whole repo -- a diff that doesn't touch the header config mustn't look like "no CSP configured".
|
|
@@ -198,7 +258,7 @@ def check_csp(root: Path, scanned: list[Path], exclude_dirs: frozenset[str], onl
|
|
|
198
258
|
return []
|
|
199
259
|
weakened: list[Finding] = []
|
|
200
260
|
found = False
|
|
201
|
-
for candidate in _walk_repo_text_files(root,
|
|
261
|
+
for candidate in _walk_repo_text_files(root, scope):
|
|
202
262
|
try:
|
|
203
263
|
text = candidate.read_text(encoding="utf-8")
|
|
204
264
|
except OSError, UnicodeDecodeError:
|
|
@@ -227,36 +287,31 @@ def run(
|
|
|
227
287
|
root: Path | None = None,
|
|
228
288
|
diff: bool = False,
|
|
229
289
|
base: str | None = None,
|
|
290
|
+
exclude: list[str] | None = None,
|
|
291
|
+
respect_gitignore: bool = True,
|
|
230
292
|
) -> InjectionResult:
|
|
293
|
+
"""`exclude`: directory names or root-relative paths/globs to skip, on top of `[tool.bdt.lint] exclude_dirs`
|
|
294
|
+
in pyproject.toml and the built-in list (node_modules, .venv, dist, ...)."""
|
|
231
295
|
root = (root or Path.cwd()).resolve()
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
"coverage",
|
|
235
|
-
"storybook-static",
|
|
236
|
-
}
|
|
296
|
+
configured = [str(d) for d in load_bdt_table("lint", root).get("exclude_dirs", []) or []]
|
|
297
|
+
scope = _ScanFilter(root, configured + list(exclude or []), respect_gitignore)
|
|
237
298
|
findings: list[Finding] = []
|
|
238
299
|
changed: list[Path] = []
|
|
239
300
|
|
|
240
301
|
if diff:
|
|
241
|
-
changed = [p for p in diff_files(root, base) if
|
|
302
|
+
changed = [p for p in diff_files(root, base) if scope.allows(p)]
|
|
242
303
|
py_files = [p for p in changed if p.suffix == ".py"]
|
|
243
304
|
fe_files = [p for p in changed if p.suffix.lower() in FRONTEND_SUFFIXES]
|
|
244
305
|
else:
|
|
245
306
|
targets = [Path(p).resolve() for p in paths] if paths else [root]
|
|
246
|
-
py_files, missing = _iter_files(targets,
|
|
247
|
-
fe_files, _ = _iter_files(targets,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
0,
|
|
252
|
-
"path-not-found",
|
|
253
|
-
f"'{p}' doesn't exist -- nothing was scanned for it.",
|
|
254
|
-
)
|
|
255
|
-
for p in missing
|
|
256
|
-
)
|
|
307
|
+
py_files, missing = _iter_files(targets, scope.dir_names, (".py",))
|
|
308
|
+
fe_files, _ = _iter_files(targets, scope.dir_names, FRONTEND_SUFFIXES)
|
|
309
|
+
py_files = [p for p in py_files if scope.allows(p)]
|
|
310
|
+
fe_files = [p for p in fe_files if scope.allows(p)]
|
|
311
|
+
findings.extend(Finding(p, 0, "path-not-found", f"'{p}' doesn't exist -- nothing was scanned for it.") for p in missing)
|
|
257
312
|
|
|
258
313
|
py_files = [p for p in py_files if not _is_test_file(p, root)]
|
|
259
|
-
fe_files = [p for p in fe_files if not _is_test_file(p, root)]
|
|
314
|
+
fe_files = [p for p in fe_files if not _is_test_file(p, root) and not _is_minified(p)]
|
|
260
315
|
|
|
261
316
|
if py_files:
|
|
262
317
|
require_sqlglot()
|
|
@@ -265,16 +320,19 @@ def run(
|
|
|
265
320
|
for path in fe_files:
|
|
266
321
|
findings.extend(check_frontend_file(path))
|
|
267
322
|
|
|
268
|
-
findings.extend(check_csp(root, py_files + fe_files,
|
|
269
|
-
return InjectionResult(findings=_filter_ignored(findings))
|
|
323
|
+
findings.extend(check_csp(root, py_files + fe_files, scope, {p.resolve() for p in changed} if diff else None))
|
|
324
|
+
return InjectionResult(findings=_dedupe(_filter_ignored(findings)))
|
|
270
325
|
|
|
271
326
|
|
|
272
|
-
def
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
327
|
+
def _dedupe(findings: list[Finding]) -> list[Finding]:
|
|
328
|
+
seen: set[tuple[Path, int, str]] = set()
|
|
329
|
+
unique: list[Finding] = []
|
|
330
|
+
for f in findings:
|
|
331
|
+
key = (f.path, f.line, f.rule)
|
|
332
|
+
if key not in seen:
|
|
333
|
+
seen.add(key)
|
|
334
|
+
unique.append(f)
|
|
335
|
+
return unique
|
|
278
336
|
|
|
279
337
|
|
|
280
338
|
def _is_test_file(path: Path, root: Path) -> bool:
|
|
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "bmsdna-devtools"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.27.0"
|
|
14
14
|
description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
|
|
15
15
|
readme = "README.md"
|
|
16
16
|
requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency
|
|
@@ -236,3 +236,65 @@ def test_diff_mode_from_subdirectory_finds_untracked_files(tmp_path: Path) -> No
|
|
|
236
236
|
(tmp_path / "backend" / "café.py").write_text("eval(y)\n")
|
|
237
237
|
result = find_injection.run([], root=tmp_path / "backend", diff=True, base="main")
|
|
238
238
|
assert [f.path.name for f in result.findings] == ["café.py"]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _rules_by_file(result) -> set[str]:
|
|
242
|
+
return {f.path.name for f in result.findings}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_exclude_dir_name_path_and_glob(tmp_path: Path) -> None:
|
|
246
|
+
for d in ("vendor", "lib/generated", "web"):
|
|
247
|
+
(tmp_path / d).mkdir(parents=True)
|
|
248
|
+
(tmp_path / d / "bad.py").write_text("eval(x)\n")
|
|
249
|
+
(tmp_path / "web" / "bundle.js").write_text("eval(x);\n")
|
|
250
|
+
(tmp_path / "web.config").write_text("Content-Security-Policy: default-src 'self'")
|
|
251
|
+
assert len(find_injection.run([], root=tmp_path).findings) == 4
|
|
252
|
+
assert len(find_injection.run([], root=tmp_path, exclude=["vendor"]).findings) == 3
|
|
253
|
+
assert len(find_injection.run([], root=tmp_path, exclude=["lib/generated"]).findings) == 3
|
|
254
|
+
assert len(find_injection.run([], root=tmp_path, exclude=["web/*.js", "vendor/"]).findings) == 2
|
|
255
|
+
assert [f.path.name for f in find_injection.run([], root=tmp_path, exclude=["vendor", "lib", "web/*.js"]).findings] == ["bad.py"]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_exclude_dirs_from_pyproject(tmp_path: Path) -> None:
|
|
259
|
+
(tmp_path / "pyproject.toml").write_text('[tool.bdt.lint]\nexclude_dirs = ["legacy"]\n')
|
|
260
|
+
(tmp_path / "legacy").mkdir()
|
|
261
|
+
(tmp_path / "legacy" / "bad.py").write_text("eval(x)\n")
|
|
262
|
+
assert find_injection.run([], root=tmp_path).findings == []
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_gitignored_files_are_skipped_unless_disabled(tmp_path: Path) -> None:
|
|
266
|
+
subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, capture_output=True)
|
|
267
|
+
(tmp_path / ".gitignore").write_text("built/\n")
|
|
268
|
+
(tmp_path / "built").mkdir()
|
|
269
|
+
(tmp_path / "built" / "bad.py").write_text("eval(x)\n")
|
|
270
|
+
(tmp_path / "src.py").write_text("eval(y)\n")
|
|
271
|
+
assert [f.path.name for f in find_injection.run([], root=tmp_path).findings] == ["src.py"]
|
|
272
|
+
assert len(find_injection.run([], root=tmp_path, respect_gitignore=False).findings) == 2
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_min_js_is_skipped_long_lines_are_not_and_findings_deduplicated(tmp_path: Path) -> None:
|
|
276
|
+
(tmp_path / "app.min.js").write_text("eval(x);")
|
|
277
|
+
(tmp_path / "bundle.js").write_text("a=1;" * 400 + "eval(x);\n")
|
|
278
|
+
(tmp_path / "dup.js").write_text("el.innerHTML = a; el.innerHTML = b;\n")
|
|
279
|
+
(tmp_path / "web.config").write_text("Content-Security-Policy: default-src 'self'")
|
|
280
|
+
result = find_injection.run([], root=tmp_path)
|
|
281
|
+
assert [(f.path.name, f.rule) for f in result.findings] == [("bundle.js", "fe-eval"), ("dup.js", "fe-inner-html")]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_sqlglot_expression_sql_is_not_flagged(tmp_path: Path) -> None:
|
|
285
|
+
(tmp_path / "a.py").write_text(
|
|
286
|
+
"""import sqlglot
|
|
287
|
+
from sqlglot import exp, select
|
|
288
|
+
from typing import cast, LiteralString
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def run(conn, t):
|
|
292
|
+
expr = select("a").from_(t).where(exp.column("b").eq(1))
|
|
293
|
+
conn.execute(expr.sql())
|
|
294
|
+
conn.execute(sqlglot.parse_one("select 1").sql(dialect="postgres"))
|
|
295
|
+
sql = exp.select("*").from_(exp.to_table(t)).sql("tsql")
|
|
296
|
+
conn.execute(sql)
|
|
297
|
+
conn.execute(cast(LiteralString, select("a").from_(t).sql(pretty=True)))
|
|
298
|
+
"""
|
|
299
|
+
)
|
|
300
|
+
assert find_injection.run([], root=tmp_path).findings == []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|