bmsdna-devtools 0.25.0__tar.gz → 0.26.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.
Files changed (74) hide show
  1. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/PKG-INFO +36 -1
  2. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/README.md +35 -0
  3. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/cli.py +17 -0
  4. bmsdna_devtools-0.26.0/bmsdna/devtools/find_injection.py +310 -0
  5. bmsdna_devtools-0.26.0/bmsdna/devtools/injection_frontend.py +237 -0
  6. bmsdna_devtools-0.26.0/bmsdna/devtools/injection_python.py +189 -0
  7. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_findings.py +1 -0
  8. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_sql.py +146 -9
  9. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/pyproject.toml +1 -1
  10. bmsdna_devtools-0.26.0/tests/test_find_injection.py +238 -0
  11. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint_sql.py +71 -0
  12. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/uv.lock +1 -1
  13. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.github/workflows/auto-release.yml +0 -0
  14. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.github/workflows/python-publish.yml +0 -0
  15. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.github/workflows/python-test.yml +0 -0
  16. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.gitignore +0 -0
  17. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.python-version +0 -0
  18. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/__init__.py +0 -0
  19. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/ado_auth.py +0 -0
  20. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/ado_issue.py +0 -0
  21. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/app_service_logs.py +0 -0
  22. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/bdt_config.py +0 -0
  23. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/cli_tools.py +0 -0
  24. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/commit.py +0 -0
  25. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/env_config.py +0 -0
  26. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/find_repo.py +0 -0
  27. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gh_issue.py +0 -0
  28. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gh_pr.py +0 -0
  29. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gitrepo.py +0 -0
  30. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint.py +0 -0
  31. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_models.py +0 -0
  32. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_tooling.py +0 -0
  33. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_typescript.py +0 -0
  34. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/logs.py +0 -0
  35. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_build.py +0 -0
  36. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  37. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_labels.py +0 -0
  38. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_markdown.py +0 -0
  39. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pull.py +0 -0
  40. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/testdb.py +0 -0
  41. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/worktree.py +0 -0
  42. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/justfile +0 -0
  43. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  44. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/conftest.py +0 -0
  45. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_ado_auth.py +0 -0
  46. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_ado_issue.py +0 -0
  47. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_ado_issue_flow.py +0 -0
  48. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_app_service_logs.py +0 -0
  49. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_bdt_config.py +0 -0
  50. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_cleanup.py +0 -0
  51. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_commit.py +0 -0
  52. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_issue.py +0 -0
  53. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pr_create.py +0 -0
  54. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pr_id.py +0 -0
  55. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pull.py +0 -0
  56. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_tools.py +0 -0
  57. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_commit.py +0 -0
  58. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_env_config.py +0 -0
  59. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_find_repo.py +0 -0
  60. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_gh_issue.py +0 -0
  61. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_gh_pr.py +0 -0
  62. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_gitrepo.py +0 -0
  63. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint.py +0 -0
  64. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint_models.py +0 -0
  65. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint_tooling.py +0 -0
  66. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint_typescript.py +0 -0
  67. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_build.py +0 -0
  68. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_build_flow.py +0 -0
  69. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_issue_link.py +0 -0
  70. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_labels.py +0 -0
  71. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_markdown.py +0 -0
  72. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pull.py +0 -0
  73. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_testdb.py +0 -0
  74. {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.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.25.0
3
+ Version: 0.26.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
@@ -575,6 +575,41 @@ already pulled in transitively via `pgdevkit[db]`, but declared explicitly as
575
575
  the `bmsdna-devtools[lint]` extra; a clear install hint is printed (not a raw
576
576
  `ImportError`) if it's ever missing.
577
577
 
578
+ ## `bdt find-injection`
579
+
580
+ Scans backend and frontend code for injection risks, and warns when a web project has no
581
+ Content-Security-Policy.
582
+
583
+ ```bash
584
+ bdt find-injection [PATHS...] # files/folders, default: current directory
585
+ bdt find-injection --diff [--base main] # only files changed on this branch + uncommitted/untracked
586
+ bdt find-injection --strict # also fail on "review" items
587
+ ```
588
+
589
+ Findings have two severities: **error** (a definite unsafe pattern, exit code 1) and **review**
590
+ (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
591
+ code 0 unless `--strict`).
592
+
593
+ - **SQL** (Python `.execute()`): f-string / `%` / concatenation / `.format()` SQL is an error.
594
+ SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
595
+ a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
596
+ call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
597
+ reports unverified calls.)
598
+ - **Other Python sinks**: `eval`/`exec`, `os.system`, `subprocess(..., shell=True)`, `yaml.load`
599
+ without a safe loader, `pickle.loads`, `Markup()`/`mark_safe()`, Jinja `autoescape=False`,
600
+ `render_template_string`.
601
+ - **Frontend** (TS/JS/JSX/Vue/Svelte/HTML): `innerHTML`/`outerHTML`/`insertAdjacentHTML`,
602
+ `dangerouslySetInnerHTML`, `v-html`, `document.write`, `eval`, `new Function`, string
603
+ `setTimeout`/`setInterval`, `javascript:` URLs, `postMessage(..., "*")`, `srcdoc`, and `<iframe>`
604
+ without `sandbox` (or with `allow-scripts` + `allow-same-origin`).
605
+ - **CSP**: `csp-missing` when a web project (frontend files or a Python web framework) has no
606
+ `Content-Security-Policy` anywhere in the repo (header in code, `<meta http-equiv>`,
607
+ `staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
608
+ `'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
609
+
610
+ Test files and generated code are skipped. Silence a confirmed-safe finding with
611
+ `# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
612
+
578
613
  ## Releasing
579
614
 
580
615
  Bump `version` in `pyproject.toml` as part of your PR, same as any other
@@ -562,6 +562,41 @@ already pulled in transitively via `pgdevkit[db]`, but declared explicitly as
562
562
  the `bmsdna-devtools[lint]` extra; a clear install hint is printed (not a raw
563
563
  `ImportError`) if it's ever missing.
564
564
 
565
+ ## `bdt find-injection`
566
+
567
+ Scans backend and frontend code for injection risks, and warns when a web project has no
568
+ Content-Security-Policy.
569
+
570
+ ```bash
571
+ bdt find-injection [PATHS...] # files/folders, default: current directory
572
+ bdt find-injection --diff [--base main] # only files changed on this branch + uncommitted/untracked
573
+ bdt find-injection --strict # also fail on "review" items
574
+ ```
575
+
576
+ Findings have two severities: **error** (a definite unsafe pattern, exit code 1) and **review**
577
+ (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
578
+ code 0 unless `--strict`).
579
+
580
+ - **SQL** (Python `.execute()`): f-string / `%` / concatenation / `.format()` SQL is an error.
581
+ SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
582
+ a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
583
+ call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
584
+ reports unverified calls.)
585
+ - **Other Python sinks**: `eval`/`exec`, `os.system`, `subprocess(..., shell=True)`, `yaml.load`
586
+ without a safe loader, `pickle.loads`, `Markup()`/`mark_safe()`, Jinja `autoescape=False`,
587
+ `render_template_string`.
588
+ - **Frontend** (TS/JS/JSX/Vue/Svelte/HTML): `innerHTML`/`outerHTML`/`insertAdjacentHTML`,
589
+ `dangerouslySetInnerHTML`, `v-html`, `document.write`, `eval`, `new Function`, string
590
+ `setTimeout`/`setInterval`, `javascript:` URLs, `postMessage(..., "*")`, `srcdoc`, and `<iframe>`
591
+ without `sandbox` (or with `allow-scripts` + `allow-same-origin`).
592
+ - **CSP**: `csp-missing` when a web project (frontend files or a Python web framework) has no
593
+ `Content-Security-Policy` anywhere in the repo (header in code, `<meta http-equiv>`,
594
+ `staticwebapp.config.json`, nginx/web.config, ...); `csp-weakened` for `'unsafe-inline'`,
595
+ `'unsafe-eval'` or a wildcard `script-src`. The CSP lookup always covers the whole repo, even with `--diff`.
596
+
597
+ Test files and generated code are skipped. Silence a confirmed-safe finding with
598
+ `# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) on, or directly above, the line.
599
+
565
600
  ## Releasing
566
601
 
567
602
  Bump `version` in `pyproject.toml` as part of your PR, same as any other
@@ -16,6 +16,7 @@ from . import ado_issue, app_service_logs, commit as commit_mod
16
16
  from . import env_config
17
17
  from . import find_repo as find_repo_mod
18
18
  from . import gh_issue, gh_pr
19
+ from . import find_injection as find_injection_mod
19
20
  from . import lint as lint_mod
20
21
  from . import logs as logs_mod
21
22
  from . import pr_build, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
@@ -926,6 +927,22 @@ def lint(
926
927
  raise typer.Exit(lint_mod.print_report(result))
927
928
 
928
929
 
930
+ @app.command("find-injection")
931
+ def find_injection_cmd(
932
+ paths: list[str] = typer.Argument(None, help="Files and/or directories to scan (default: current directory, recursive)."),
933
+ diff: bool = typer.Option(False, "--diff", help="Scan only files changed on this branch vs --base, plus uncommitted and untracked files."),
934
+ base: str = typer.Option(None, "--base", help="Branch/ref to diff against with --diff (default: origin's default branch)."),
935
+ strict: bool = typer.Option(False, "--strict", help="Also exit 1 when there are items to review (default: only definite errors fail)."),
936
+ ) -> None:
937
+ """Find injection risks (bmsuisse/devtools#54) in backend and frontend code: SQL built from f-strings/
938
+ concatenation or from an unverifiable function call, eval/exec/shell/unsafe deserialization in Python,
939
+ innerHTML/dangerouslySetInnerHTML/eval/new Function/un-sandboxed iframes in the frontend, and a missing
940
+ or weakened Content-Security-Policy. Definite problems are errors; the rest are listed for a human/AI to verify.
941
+ """
942
+ result = find_injection_mod.run(paths or [], diff=diff, base=base)
943
+ raise typer.Exit(find_injection_mod.print_report(result, strict=strict))
944
+
945
+
929
946
  @app.command("find-repo")
930
947
  def find_repo_cmd(
931
948
  name: str,
@@ -0,0 +1,310 @@
1
+ """`bdt find-injection` (bmsuisse/devtools#54): scan a folder, explicit files, or a git diff for
2
+ injection risks in both backend (Python) and frontend (TS/JS/HTML/Vue) code, and warn when a
3
+ web project has no Content-Security-Policy.
4
+
5
+ Findings come in two severities: "error" (a definite unsafe pattern) and "review" (depends on
6
+ where a value comes from -- a human or AI has to verify it). Exit code 1 for any error;
7
+ review items only fail the run with `strict=True`.
8
+
9
+ A finding is silenced with `# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>`
10
+ (TS/JS) on, or on the comment line directly above, the flagged line.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from .injection_frontend import (
23
+ FRONTEND_SUFFIXES,
24
+ check_frontend_file,
25
+ find_csp_weakening,
26
+ has_csp,
27
+ )
28
+ from .injection_python import check_python_sinks
29
+ from .lint import _DEFAULT_EXCLUDE_DIR_NAMES, _iter_files
30
+ from .lint_findings import Finding, render_findings
31
+ from .lint_sql import check_sql_file, require_sqlglot
32
+ from .lint_typescript import DEFAULT_TS_EXCLUDE_DIR_NAMES, is_excluded_ts_file
33
+
34
+ SQL_INJECTION_RULES = frozenset(
35
+ {
36
+ "sql-fstring-injection",
37
+ "sql-percent-format-injection",
38
+ "sql-concat-injection",
39
+ "sql-format-injection",
40
+ }
41
+ )
42
+ _IGNORE_RE = re.compile(r"bdt-lint:\s*ignore\s+([\w-]+(?:\s*,\s*[\w-]+)*)")
43
+ _WEB_FRAMEWORK_RE = re.compile(
44
+ r"^\s*(?:from|import)\s+(?:fastapi|flask|starlette|django|aiohttp|quart|sanic|litestar)\b",
45
+ re.MULTILINE,
46
+ )
47
+ _CONFIG_SUFFIXES = (
48
+ ".json",
49
+ ".conf",
50
+ ".config",
51
+ ".yaml",
52
+ ".yml",
53
+ ".toml",
54
+ ".cjs",
55
+ ".mjs",
56
+ ".js",
57
+ ".ts",
58
+ ".py",
59
+ ".html",
60
+ ".htm",
61
+ ".tsx",
62
+ )
63
+ _CONFIG_NAMES = frozenset(
64
+ {
65
+ "web.config",
66
+ "nginx.conf",
67
+ "staticwebapp.config.json",
68
+ "_headers",
69
+ "Caddyfile",
70
+ ".htaccess",
71
+ }
72
+ )
73
+ _EXCLUDE_TEST_DIR_NAMES = DEFAULT_TS_EXCLUDE_DIR_NAMES - {"generated", "__generated__"}
74
+ _SKIP_NAMES = frozenset({"package-lock.json", "pnpm-lock.yaml", "yarn.lock", "uv.lock", "poetry.lock", "npm-shrinkwrap.json"})
75
+ _MAX_CSP_FILE_BYTES = 1_000_000
76
+ _TS_LIKE = (".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs")
77
+
78
+ AI_INSTRUCTION = (
79
+ "AI reviewer: for every [review] item above, open the code, trace where the value comes from, and decide whether "
80
+ "attacker-controlled input can reach it. Fix real risks (parameterise / sanitise / sandbox); for confirmed-safe cases add "
81
+ "`# bdt-lint: ignore <rule>` (Python) or `// bdt-lint: ignore <rule>` (TS/JS) with a one-line reason."
82
+ )
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class InjectionResult:
87
+ findings: list[Finding]
88
+
89
+ @property
90
+ def errors(self) -> list[Finding]:
91
+ return [f for f in self.findings if f.severity == "error"]
92
+
93
+ @property
94
+ def reviews(self) -> list[Finding]:
95
+ return [f for f in self.findings if f.severity == "review"]
96
+
97
+
98
+ def _git(args: list[str], cwd: Path) -> str:
99
+ try:
100
+ return subprocess.check_output(["git", *args], encoding="utf-8", cwd=cwd, stderr=subprocess.PIPE)
101
+ except FileNotFoundError:
102
+ sys.exit("'git' is required for --diff but wasn't found on PATH.")
103
+ except subprocess.CalledProcessError as e:
104
+ sys.exit(f"git {' '.join(args)} failed: {(e.stderr or '').strip()}")
105
+
106
+
107
+ def diff_files(root: Path, base: str | None = None) -> list[Path]:
108
+ """Files added/changed on this branch vs `base` (default: origin's default branch, else main), plus
109
+ uncommitted and untracked ones. Deleted files are skipped."""
110
+ repo = Path(_git(["rev-parse", "--show-toplevel"], root).strip())
111
+ candidates = [base] if base else []
112
+ if not base:
113
+ head = subprocess.run(
114
+ ["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
115
+ cwd=root,
116
+ capture_output=True,
117
+ encoding="utf-8",
118
+ )
119
+ candidates += [head.stdout.strip()] if head.returncode == 0 and head.stdout.strip() else []
120
+ candidates += ["origin/main", "main", "origin/master", "master"]
121
+ chosen = next(
122
+ (c for c in candidates if c and subprocess.run(["git", "rev-parse", "--verify", "-q", c], cwd=root, capture_output=True).returncode == 0),
123
+ None,
124
+ )
125
+ if chosen is None:
126
+ sys.exit(f"Couldn't find a base branch to diff against (tried {', '.join(c for c in candidates if c)}); pass --base.")
127
+ names: set[str] = set()
128
+ names.update(_git(["diff", "--name-only", "-z", "--diff-filter=ACMR", f"{chosen}...HEAD"], root).split("\0"))
129
+ names.update(_git(["diff", "--name-only", "-z", "--diff-filter=ACMR", "HEAD"], root).split("\0"))
130
+ names.update(_git(["ls-files", "--others", "--exclude-standard", "--full-name", "-z"], root).split("\0"))
131
+ return sorted(p for p in (repo / n for n in names if n) if p.is_file())
132
+
133
+
134
+ def _pragma_ignored(lines: list[str], finding: Finding) -> bool:
135
+ if finding.line <= 0:
136
+ return False
137
+ for number in (finding.line, finding.line - 1):
138
+ if not 1 <= number <= len(lines):
139
+ continue
140
+ text = lines[number - 1]
141
+ if number != finding.line and not text.lstrip().startswith(("#", "//", "/*", "*", "<!--")):
142
+ continue
143
+ match = _IGNORE_RE.search(text)
144
+ if match and finding.rule in {r.strip() for r in match.group(1).split(",")}:
145
+ return True
146
+ return False
147
+
148
+
149
+ def _filter_ignored(findings: list[Finding]) -> list[Finding]:
150
+ cache: dict[Path, list[str]] = {}
151
+ kept: list[Finding] = []
152
+ for finding in findings:
153
+ if finding.path not in cache:
154
+ try:
155
+ cache[finding.path] = finding.path.read_text(encoding="utf-8").splitlines()
156
+ except OSError, UnicodeDecodeError:
157
+ cache[finding.path] = []
158
+ if not _pragma_ignored(cache[finding.path], finding):
159
+ kept.append(finding)
160
+ return kept
161
+
162
+
163
+ def _scan_python(path: Path) -> list[Finding]:
164
+ sql = [f for f in check_sql_file(path, review=True) if f.rule in SQL_INJECTION_RULES or f.rule in ("sql-unverified-call", "sql-unverified-cast")]
165
+ return sql + check_python_sinks(path)
166
+
167
+
168
+ def _walk_repo_text_files(root: Path, exclude_dirs: frozenset[str]):
169
+ for dirpath, dirnames, filenames in os.walk(root):
170
+ dirnames[:] = [d for d in dirnames if d not in exclude_dirs]
171
+ for name in filenames:
172
+ if name in _SKIP_NAMES or name.endswith(".min.js") or not (name in _CONFIG_NAMES or name.endswith(_CONFIG_SUFFIXES)):
173
+ continue
174
+ candidate = Path(dirpath, name)
175
+ try:
176
+ if candidate.stat().st_size <= _MAX_CSP_FILE_BYTES:
177
+ yield candidate
178
+ except OSError:
179
+ continue
180
+
181
+
182
+ def check_csp(root: Path, scanned: list[Path], exclude_dirs: frozenset[str], only: set[Path] | None = None) -> list[Finding]:
183
+ """Warns when the project serves a web UI/API (frontend files scanned, or a Python web framework
184
+ imported) but no file anywhere under `root` mentions a Content-Security-Policy. Always searches the
185
+ whole repo -- a diff that doesn't touch the header config mustn't look like "no CSP configured".
186
+ `only` (diff mode) restricts `csp-weakened` findings to the changed files."""
187
+ is_web = any(p.suffix.lower() in FRONTEND_SUFFIXES for p in scanned)
188
+ if not is_web:
189
+ for p in scanned:
190
+ if p.suffix == ".py":
191
+ try:
192
+ if _WEB_FRAMEWORK_RE.search(p.read_text(encoding="utf-8")):
193
+ is_web = True
194
+ break
195
+ except OSError, UnicodeDecodeError:
196
+ continue
197
+ if not is_web:
198
+ return []
199
+ weakened: list[Finding] = []
200
+ found = False
201
+ for candidate in _walk_repo_text_files(root, exclude_dirs):
202
+ try:
203
+ text = candidate.read_text(encoding="utf-8")
204
+ except OSError, UnicodeDecodeError:
205
+ continue
206
+ if has_csp(text):
207
+ found = True
208
+ weakened.extend(find_csp_weakening(candidate, text))
209
+ if found:
210
+ return [f for f in weakened if only is None or f.path.resolve() in only]
211
+ return [
212
+ Finding(
213
+ root,
214
+ 0,
215
+ "csp-missing",
216
+ "No Content-Security-Policy configured anywhere in the repo (response header, `<meta http-equiv>`, "
217
+ "staticwebapp.config.json, nginx/web.config, ...). A CSP is the main defence-in-depth against XSS -- add one "
218
+ "(start with `default-src 'self'` and no 'unsafe-inline'/'unsafe-eval').",
219
+ severity="review",
220
+ )
221
+ ]
222
+
223
+
224
+ def run(
225
+ paths: list[str],
226
+ *,
227
+ root: Path | None = None,
228
+ diff: bool = False,
229
+ base: str | None = None,
230
+ ) -> InjectionResult:
231
+ root = (root or Path.cwd()).resolve()
232
+ exclude_dirs = _DEFAULT_EXCLUDE_DIR_NAMES | {
233
+ ".next",
234
+ "coverage",
235
+ "storybook-static",
236
+ }
237
+ findings: list[Finding] = []
238
+ changed: list[Path] = []
239
+
240
+ if diff:
241
+ changed = [p for p in diff_files(root, base) if not _in_excluded_dir(p, root, exclude_dirs)]
242
+ py_files = [p for p in changed if p.suffix == ".py"]
243
+ fe_files = [p for p in changed if p.suffix.lower() in FRONTEND_SUFFIXES]
244
+ else:
245
+ targets = [Path(p).resolve() for p in paths] if paths else [root]
246
+ py_files, missing = _iter_files(targets, exclude_dirs, (".py",))
247
+ fe_files, _ = _iter_files(targets, exclude_dirs, FRONTEND_SUFFIXES)
248
+ findings.extend(
249
+ Finding(
250
+ p,
251
+ 0,
252
+ "path-not-found",
253
+ f"'{p}' doesn't exist -- nothing was scanned for it.",
254
+ )
255
+ for p in missing
256
+ )
257
+
258
+ 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)]
260
+
261
+ if py_files:
262
+ require_sqlglot()
263
+ for path in py_files:
264
+ findings.extend(_scan_python(path))
265
+ for path in fe_files:
266
+ findings.extend(check_frontend_file(path))
267
+
268
+ findings.extend(check_csp(root, py_files + fe_files, exclude_dirs, {p.resolve() for p in changed} if diff else None))
269
+ return InjectionResult(findings=_filter_ignored(findings))
270
+
271
+
272
+ def _in_excluded_dir(path: Path, root: Path, exclude_dirs: frozenset[str]) -> bool:
273
+ try:
274
+ parts = path.relative_to(root).parts[:-1]
275
+ except ValueError:
276
+ parts = path.parts[:-1]
277
+ return any(part in exclude_dirs for part in parts)
278
+
279
+
280
+ def _is_test_file(path: Path, root: Path) -> bool:
281
+ try:
282
+ parts = path.relative_to(root).parts[:-1]
283
+ except ValueError:
284
+ parts = ()
285
+ name = path.name
286
+ return (
287
+ any(part in _EXCLUDE_TEST_DIR_NAMES for part in parts)
288
+ or name.startswith("test_")
289
+ or name.endswith("_test.py")
290
+ or name == "conftest.py"
291
+ or (path.suffix in _TS_LIKE and is_excluded_ts_file(path, repo_root=root))
292
+ )
293
+
294
+
295
+ def print_report(result: InjectionResult, *, strict: bool = False) -> int:
296
+ """Prints the report and returns the exit code (1 on any error, or on any review item with `strict`)."""
297
+ if not result.findings:
298
+ print("bdt find-injection: no injection risks found")
299
+ return 0
300
+ errors, reviews = result.errors, result.reviews
301
+ if errors:
302
+ print("Errors:")
303
+ print(render_findings(errors))
304
+ if reviews:
305
+ print(("\n" if errors else "") + "Review (verify these are safe):")
306
+ print(render_findings(reviews))
307
+ print(f"\n{len(errors)} error(s), {len(reviews)} item(s) to review.")
308
+ if reviews:
309
+ print(AI_INSTRUCTION)
310
+ return 1 if errors or (strict and reviews) else 0
@@ -0,0 +1,237 @@
1
+ """`bdt find-injection`'s frontend rules (bmsuisse/devtools#54): DOM-XSS sinks in TS/JS/JSX/Vue
2
+ (comments and string contents are masked with lint_typescript's tokenizer so a mention inside
3
+ a comment or string never matches), un-sandboxed iframes, and Content-Security-Policy checks.
4
+
5
+ Deliberately heuristic, like lint_typescript: no JS parser. Findings that depend on where a
6
+ value comes from are "review" items.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from .lint_findings import Finding
15
+ from .lint_typescript import _line_of, _mask
16
+
17
+ FRONTEND_SUFFIXES = (
18
+ ".ts",
19
+ ".tsx",
20
+ ".mts",
21
+ ".js",
22
+ ".jsx",
23
+ ".mjs",
24
+ ".vue",
25
+ ".svelte",
26
+ ".html",
27
+ ".htm",
28
+ )
29
+ _HTML_SUFFIXES = (".html", ".htm")
30
+ _MASKABLE_SUFFIXES = (".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs")
31
+
32
+ _CSP_RE = re.compile(r"content-security-policy", re.IGNORECASE)
33
+ _CSP_WEAK_RE = re.compile(r"'unsafe-(?:inline|eval)'|(?:script|default)-src[^;\n]*\s\*(?=[\s;\"'`]|$)", re.IGNORECASE)
34
+
35
+ _INNER_HTML_ASSIGN_RE = re.compile(r"\.\s*(?:inner|outer)HTML\s*\+?=(?!=)")
36
+ _INSERT_HTML_RE = re.compile(r"\.\s*insertAdjacentHTML\s*\(")
37
+ _DOC_WRITE_RE = re.compile(r"(?<![\w$])document\s*\.\s*write(?:ln)?\s*\(")
38
+ _EVAL_RE = re.compile(r"(?<![\w$.])eval\s*\(")
39
+ _NEW_FUNCTION_RE = re.compile(r"(?<![\w$.])new\s+Function\s*\(")
40
+ _TIMER_STRING_RE = re.compile(r"(?<![\w$.])(?:setTimeout|setInterval)\s*\(\s*(?:['\"`]|[\w$.]+\s*\+)")
41
+ _DANGEROUS_HTML_RE = re.compile(r"\bdangerouslySetInnerHTML\b")
42
+ _V_HTML_RE = re.compile(r"\bv-html\s*=|\{@html\b")
43
+ _JS_URL_ATTR_RE = re.compile(r"""(?:href|src|action|formaction)\s*=\s*\{?\s*[`'"]\s*javascript\s*:""", re.IGNORECASE)
44
+ _POST_MESSAGE_STAR_RE = re.compile(r"""postMessage\s*\([^;]*?,\s*['"]\*['"]\s*[,)]""")
45
+ _IFRAME_RE = re.compile(r"<iframe\b", re.IGNORECASE)
46
+ _SRCDOC_RE = re.compile(r"\bsrc[dD]oc\b\s*=")
47
+
48
+
49
+ def _sink(path: Path, text: str, pos: int, rule: str, message: str, severity: str) -> Finding:
50
+ return Finding(path, _line_of(text, pos), rule, message, severity=severity)
51
+
52
+
53
+ def _iframe_tag(code: str, start: int) -> str:
54
+ """The `<iframe ...>` opening tag (attribute values may contain `>` inside `{...}` or quotes)."""
55
+ depth = 0
56
+ quote = ""
57
+ for k in range(start, len(code)):
58
+ c = code[k]
59
+ if quote:
60
+ if c == quote:
61
+ quote = ""
62
+ elif c in "\"'":
63
+ quote = c
64
+ elif c == "{":
65
+ depth += 1
66
+ elif c == "}":
67
+ depth = max(0, depth - 1)
68
+ elif c == ">" and depth == 0:
69
+ return code[start : k + 1]
70
+ return code[start:]
71
+
72
+
73
+ def _check_iframes(path: Path, code: str, skeleton: str) -> list[Finding]:
74
+ findings: list[Finding] = []
75
+ for match in _IFRAME_RE.finditer(skeleton):
76
+ tag = _iframe_tag(code, match.start())
77
+ sandbox = re.search(
78
+ r"(?<=\s)sandbox(?![\w-])(?:\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|\{\s*[\"'`]([^\"'`]*)[\"'`]\s*\}))?",
79
+ tag,
80
+ )
81
+ if sandbox is None:
82
+ findings.append(
83
+ _sink(
84
+ path,
85
+ code,
86
+ match.start(),
87
+ "fe-iframe-no-sandbox",
88
+ "`<iframe>` without a `sandbox` attribute -- embedded content runs with the page's full privileges. "
89
+ "Add `sandbox` (with only the allow-* flags it needs).",
90
+ "error",
91
+ )
92
+ )
93
+ continue
94
+ flags = next((g for g in sandbox.groups() if g), "")
95
+ if "allow-scripts" in flags and "allow-same-origin" in flags:
96
+ findings.append(
97
+ _sink(
98
+ path,
99
+ code,
100
+ match.start(),
101
+ "fe-iframe-sandbox-escape",
102
+ "`sandbox` combines `allow-scripts` and `allow-same-origin`, which lets the framed page remove its own sandbox.",
103
+ "error",
104
+ )
105
+ )
106
+ return findings
107
+
108
+
109
+ def check_frontend_file(path: Path, source: str | None = None) -> list[Finding]:
110
+ if source is None:
111
+ try:
112
+ source = path.read_text(encoding="utf-8")
113
+ except OSError, UnicodeDecodeError:
114
+ return []
115
+ full_text: str = source
116
+ suffix = path.suffix.lower()
117
+ if suffix in _MASKABLE_SUFFIXES:
118
+ masked = _mask(source)
119
+ skeleton, code = masked.skeleton, masked.code
120
+ else: # .vue/.svelte/.html: `//` appears in URLs and `'` in prose, so the JS tokenizer would mangle them
121
+ skeleton = code = source
122
+
123
+ findings: list[Finding] = []
124
+
125
+ def scan(
126
+ regex: re.Pattern[str],
127
+ rule: str,
128
+ message: str,
129
+ severity: str,
130
+ text: str | None = None,
131
+ ) -> None:
132
+ haystack = skeleton if text is None else text
133
+ findings.extend(_sink(path, full_text, m.start(), rule, message, severity) for m in regex.finditer(haystack))
134
+
135
+ if suffix not in _HTML_SUFFIXES:
136
+ scan(
137
+ _INNER_HTML_ASSIGN_RE,
138
+ "fe-inner-html",
139
+ "Assigning to `innerHTML`/`outerHTML` -- XSS if the value contains user data. AI reviewer: verify it's sanitised "
140
+ "(e.g. DOMPurify) or use `textContent`.",
141
+ "review",
142
+ )
143
+ scan(
144
+ _INSERT_HTML_RE,
145
+ "fe-inner-html",
146
+ "`insertAdjacentHTML` parses its argument as HTML -- XSS if it contains user data. AI reviewer: verify it's sanitised.",
147
+ "review",
148
+ )
149
+ scan(
150
+ _DOC_WRITE_RE,
151
+ "fe-document-write",
152
+ "`document.write()` injects raw HTML -- avoid it.",
153
+ "error",
154
+ )
155
+ scan(
156
+ _EVAL_RE,
157
+ "fe-eval",
158
+ "`eval()` executes arbitrary code -- remove it.",
159
+ "error",
160
+ )
161
+ scan(
162
+ _NEW_FUNCTION_RE,
163
+ "fe-new-function",
164
+ "`new Function()` is `eval` in disguise -- remove it.",
165
+ "error",
166
+ )
167
+ scan(
168
+ _TIMER_STRING_RE,
169
+ "fe-timer-string",
170
+ "`setTimeout`/`setInterval` with a string argument evaluates it as code -- pass a function.",
171
+ "error",
172
+ )
173
+ scan(
174
+ _DANGEROUS_HTML_RE,
175
+ "fe-dangerously-set-inner-html",
176
+ "`dangerouslySetInnerHTML` -- XSS if the HTML contains user data. AI reviewer: verify it's sanitised (DOMPurify) or static.",
177
+ "review",
178
+ )
179
+ scan(
180
+ _POST_MESSAGE_STAR_RE,
181
+ "fe-post-message-star",
182
+ "`postMessage(..., '*')` sends to any origin -- pass the exact target origin.",
183
+ "review",
184
+ code,
185
+ )
186
+ scan(
187
+ _SRCDOC_RE,
188
+ "fe-iframe-srcdoc",
189
+ "`srcdoc` renders an HTML string inside a frame -- verify no user data reaches it.",
190
+ "review",
191
+ )
192
+ findings.extend(_check_iframes(path, code, skeleton))
193
+ else:
194
+ findings.extend(_check_iframes(path, code, code))
195
+
196
+ if suffix in (".vue", ".svelte"):
197
+ scan(
198
+ _V_HTML_RE,
199
+ "fe-v-html",
200
+ "`v-html`/`{@html}` renders raw HTML -- XSS if it contains user data. AI reviewer: verify it's sanitised.",
201
+ "review",
202
+ )
203
+ scan(
204
+ _JS_URL_ATTR_RE,
205
+ "fe-javascript-url",
206
+ "`javascript:` URL executes code on click -- use an event handler.",
207
+ "error",
208
+ code,
209
+ )
210
+ return findings
211
+
212
+
213
+ def find_csp_weakening(path: Path, source: str | None = None) -> list[Finding]:
214
+ """`'unsafe-inline'`/`'unsafe-eval'`/wildcard script-src inside a file that defines a CSP."""
215
+ if source is None:
216
+ try:
217
+ source = path.read_text(encoding="utf-8")
218
+ except OSError, UnicodeDecodeError:
219
+ return []
220
+ if not _CSP_RE.search(source) and "script-src" not in source.lower() and "default-src" not in source.lower():
221
+ return []
222
+ return [
223
+ _sink(
224
+ path,
225
+ source,
226
+ m.start(),
227
+ "csp-weakened",
228
+ f"CSP contains `{m.group(0).strip()}`, which weakens XSS protection. AI reviewer: verify this relaxation is necessary "
229
+ "(prefer nonces/hashes).",
230
+ "review",
231
+ )
232
+ for m in _CSP_WEAK_RE.finditer(source)
233
+ ]
234
+
235
+
236
+ def has_csp(source: str) -> bool:
237
+ return _CSP_RE.search(source) is not None