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.
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/PKG-INFO +36 -1
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/README.md +35 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/cli.py +17 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/find_injection.py +310 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/injection_frontend.py +237 -0
- bmsdna_devtools-0.26.0/bmsdna/devtools/injection_python.py +189 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_findings.py +1 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_sql.py +146 -9
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/pyproject.toml +1 -1
- bmsdna_devtools-0.26.0/tests/test_find_injection.py +238 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint_sql.py +71 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/uv.lock +1 -1
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.gitignore +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/.python-version +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/ado_issue.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gh_issue.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/justfile +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.25.0 → bmsdna_devtools-0.26.0}/tests/test_testdb.py +0 -0
- {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.
|
|
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
|