fenceline 1.0.2__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.
- fenceline-1.0.2/PKG-INFO +138 -0
- fenceline-1.0.2/README.md +116 -0
- fenceline-1.0.2/pyproject.toml +68 -0
- fenceline-1.0.2/setup.cfg +4 -0
- fenceline-1.0.2/src/fenceline/__init__.py +12 -0
- fenceline-1.0.2/src/fenceline/ast_helpers.py +174 -0
- fenceline-1.0.2/src/fenceline/baseline.py +48 -0
- fenceline-1.0.2/src/fenceline/checks/__init__.py +177 -0
- fenceline-1.0.2/src/fenceline/checks/ast_checks.py +722 -0
- fenceline-1.0.2/src/fenceline/checks/manifest_checks.py +148 -0
- fenceline-1.0.2/src/fenceline/checks/text_checks.py +1360 -0
- fenceline-1.0.2/src/fenceline/cli.py +194 -0
- fenceline-1.0.2/src/fenceline/config.py +72 -0
- fenceline-1.0.2/src/fenceline/models.py +32 -0
- fenceline-1.0.2/src/fenceline/py.typed +0 -0
- fenceline-1.0.2/src/fenceline/reporting.py +191 -0
- fenceline-1.0.2/src/fenceline/scanner.py +67 -0
- fenceline-1.0.2/src/fenceline/suppression.py +43 -0
- fenceline-1.0.2/src/fenceline.egg-info/PKG-INFO +138 -0
- fenceline-1.0.2/src/fenceline.egg-info/SOURCES.txt +23 -0
- fenceline-1.0.2/src/fenceline.egg-info/dependency_links.txt +1 -0
- fenceline-1.0.2/src/fenceline.egg-info/entry_points.txt +2 -0
- fenceline-1.0.2/src/fenceline.egg-info/requires.txt +1 -0
- fenceline-1.0.2/src/fenceline.egg-info/top_level.txt +1 -0
- fenceline-1.0.2/tests/test_fenceline.py +485 -0
fenceline-1.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fenceline
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Zero-day security scanner for the Boti workspace
|
|
5
|
+
Author: Luis Valverde
|
|
6
|
+
Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/lvalverdeb/fenceline
|
|
9
|
+
Project-URL: Repository, https://github.com/lvalverdeb/fenceline
|
|
10
|
+
Project-URL: Documentation, https://github.com/lvalverdeb/fenceline#readme
|
|
11
|
+
Project-URL: Issues, https://github.com/lvalverdeb/fenceline/issues
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Requires-Python: >=3.13
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: boti>=1.0.0
|
|
22
|
+
|
|
23
|
+
# fenceline
|
|
24
|
+
|
|
25
|
+
Zero-day security scanner for the Boti workspace.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv add fenceline
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Maps every finding to a CWE from the 2025 CWE Top 25, OWASP Top 10:2025, and known
|
|
34
|
+
Python zero-day exploit patterns (pickle bypass CVE-2026-56315, PyYAML shadow
|
|
35
|
+
vulnerability CVE-2026-24009, LangChain SSTI CVE-2025-68664, dependency confusion
|
|
36
|
+
CVE-2025-61774, and more — see the CWE reference printed at the end of every
|
|
37
|
+
text-mode report). 56 checks total: most are AST-based (won't false-positive on a
|
|
38
|
+
dangerous call mentioned in a docstring or comment); the rest are line-regex checks
|
|
39
|
+
for surface-syntax patterns that don't need full parsing.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv run fenceline
|
|
45
|
+
uv run fenceline --json > report.json
|
|
46
|
+
uv run fenceline -q
|
|
47
|
+
uv run fenceline --fail-on critical
|
|
48
|
+
uv run fenceline --package my-lib=my-lib/src/my_lib
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Exit codes: `0` (no findings at or above `--fail-on`), `1` (one or more).
|
|
52
|
+
|
|
53
|
+
### Options
|
|
54
|
+
|
|
55
|
+
| Flag | Default | Description |
|
|
56
|
+
| --- | --- | --- |
|
|
57
|
+
| `--json` | off | Machine-readable output |
|
|
58
|
+
| `--quiet` / `-q` | off | Suppress the banner |
|
|
59
|
+
| `--fail-on` | `high` | Severity threshold (`critical`\|`high`\|`medium`\|`low`\|`info`) for the exit code |
|
|
60
|
+
| `--confidence-min` | `low` | Drop findings below this confidence (`high`\|`medium`\|`low`) |
|
|
61
|
+
| `--package NAME=PATH` | — | Add or override a scan target package (repeatable) |
|
|
62
|
+
| `--baseline PATH` | — | Only report/fail on findings not already present in this baseline |
|
|
63
|
+
| `--write-baseline PATH` | — | Snapshot current findings to PATH and exit 0 |
|
|
64
|
+
|
|
65
|
+
By default, fenceline scans `boti`, `boti-data`, `boti-dask`, and itself.
|
|
66
|
+
|
|
67
|
+
### Severity vs. confidence
|
|
68
|
+
|
|
69
|
+
Every finding carries two independent ratings, the same distinction Bandit
|
|
70
|
+
makes: **severity** is how bad it would be if the finding is real; **confidence**
|
|
71
|
+
is how sure the check is that it *is* real. An AST-based check that matched a
|
|
72
|
+
real `Call` node is `HIGH` confidence; a line-regex check that can't fully
|
|
73
|
+
rule out a docstring or string-literal mention is `MEDIUM`; a handful of
|
|
74
|
+
inherently heuristic checks (timing-attack keyword proximity, ReDoS
|
|
75
|
+
backtracking-risk judgment, `debug=True` pattern matching) are `LOW`. Use
|
|
76
|
+
`--confidence-min medium` to cut noise from the fuzzier checks without
|
|
77
|
+
raising your severity bar.
|
|
78
|
+
|
|
79
|
+
### Baselining an existing codebase
|
|
80
|
+
|
|
81
|
+
Adopting fenceline on a codebase with existing findings doesn't mean fixing
|
|
82
|
+
all of them before CI can pass:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
uv run fenceline --write-baseline fenceline-baseline.json # snapshot today's findings
|
|
86
|
+
uv run fenceline --baseline fenceline-baseline.json # only new findings fail CI
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A finding is matched against the baseline by `(cwe_id, file, code_snippet)`,
|
|
90
|
+
not line number, so it keeps matching across unrelated edits that shift line
|
|
91
|
+
numbers elsewhere in the file. Re-run `--write-baseline` periodically (or
|
|
92
|
+
whenever a baselined finding is deliberately fixed) to keep it current.
|
|
93
|
+
|
|
94
|
+
### Suppressing a specific finding
|
|
95
|
+
|
|
96
|
+
`# nosec` (bare) suppresses every finding on that line; `# nosec CWE-502`
|
|
97
|
+
suppresses only that CWE, leaving any other finding on the same line intact
|
|
98
|
+
— the same convention Bandit uses, with CWE IDs instead of Bandit's own
|
|
99
|
+
B-numbers:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
pickle.loads(data) # nosec CWE-502 -- trusted internal cache, not user input
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Extending fenceline with your own checks
|
|
106
|
+
|
|
107
|
+
Built-in checks aren't dynamically discovered — they're a fixed list. Your
|
|
108
|
+
own checks are, via a `fenceline.checks` entry point in *your own*
|
|
109
|
+
`pyproject.toml`:
|
|
110
|
+
|
|
111
|
+
```toml
|
|
112
|
+
[project.entry-points."fenceline.checks"]
|
|
113
|
+
"My Custom Check (CWE-000)" = "my_package.checks:my_check_function"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The entry point's own name becomes the check's display name; the object it
|
|
117
|
+
points at must be a function with the same `(path, lines, tree) ->
|
|
118
|
+
list[Finding]` signature every built-in check has. A plugin that fails to
|
|
119
|
+
import is skipped with a warning rather than crashing the whole scan.
|
|
120
|
+
|
|
121
|
+
## Design
|
|
122
|
+
|
|
123
|
+
- `fenceline.models` — the `Finding` dataclass, severity ordering, and confidence ordering.
|
|
124
|
+
- `fenceline.config` — workspace-root discovery and the default package registry.
|
|
125
|
+
- `fenceline.ast_helpers` — shared AST/text-matching helpers used across checks.
|
|
126
|
+
- `fenceline.scanner` — file discovery and reading.
|
|
127
|
+
- `fenceline.checks` — the built-in check registry, plus `fenceline.checks`
|
|
128
|
+
entry-point discovery for third-party checks.
|
|
129
|
+
- `fenceline.checks.ast_checks` — checks that walk the parsed AST (won't match
|
|
130
|
+
a dangerous call mentioned in a docstring or string literal).
|
|
131
|
+
- `fenceline.checks.text_checks` — checks that scan raw source lines for
|
|
132
|
+
surface-syntax patterns.
|
|
133
|
+
- `fenceline.checks.manifest_checks` — checks over dependency manifests
|
|
134
|
+
(`pyproject.toml`, `requirements.txt`, etc.), not Python source.
|
|
135
|
+
- `fenceline.suppression` — `# nosec` inline-suppression parsing.
|
|
136
|
+
- `fenceline.baseline` — baseline snapshot/diff for adopting fenceline on an existing codebase.
|
|
137
|
+
- `fenceline.reporting` — text/JSON report rendering.
|
|
138
|
+
- `fenceline.cli` — argument parsing and the scan loop.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# fenceline
|
|
2
|
+
|
|
3
|
+
Zero-day security scanner for the Boti workspace.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv add fenceline
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Maps every finding to a CWE from the 2025 CWE Top 25, OWASP Top 10:2025, and known
|
|
12
|
+
Python zero-day exploit patterns (pickle bypass CVE-2026-56315, PyYAML shadow
|
|
13
|
+
vulnerability CVE-2026-24009, LangChain SSTI CVE-2025-68664, dependency confusion
|
|
14
|
+
CVE-2025-61774, and more — see the CWE reference printed at the end of every
|
|
15
|
+
text-mode report). 56 checks total: most are AST-based (won't false-positive on a
|
|
16
|
+
dangerous call mentioned in a docstring or comment); the rest are line-regex checks
|
|
17
|
+
for surface-syntax patterns that don't need full parsing.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uv run fenceline
|
|
23
|
+
uv run fenceline --json > report.json
|
|
24
|
+
uv run fenceline -q
|
|
25
|
+
uv run fenceline --fail-on critical
|
|
26
|
+
uv run fenceline --package my-lib=my-lib/src/my_lib
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Exit codes: `0` (no findings at or above `--fail-on`), `1` (one or more).
|
|
30
|
+
|
|
31
|
+
### Options
|
|
32
|
+
|
|
33
|
+
| Flag | Default | Description |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `--json` | off | Machine-readable output |
|
|
36
|
+
| `--quiet` / `-q` | off | Suppress the banner |
|
|
37
|
+
| `--fail-on` | `high` | Severity threshold (`critical`\|`high`\|`medium`\|`low`\|`info`) for the exit code |
|
|
38
|
+
| `--confidence-min` | `low` | Drop findings below this confidence (`high`\|`medium`\|`low`) |
|
|
39
|
+
| `--package NAME=PATH` | — | Add or override a scan target package (repeatable) |
|
|
40
|
+
| `--baseline PATH` | — | Only report/fail on findings not already present in this baseline |
|
|
41
|
+
| `--write-baseline PATH` | — | Snapshot current findings to PATH and exit 0 |
|
|
42
|
+
|
|
43
|
+
By default, fenceline scans `boti`, `boti-data`, `boti-dask`, and itself.
|
|
44
|
+
|
|
45
|
+
### Severity vs. confidence
|
|
46
|
+
|
|
47
|
+
Every finding carries two independent ratings, the same distinction Bandit
|
|
48
|
+
makes: **severity** is how bad it would be if the finding is real; **confidence**
|
|
49
|
+
is how sure the check is that it *is* real. An AST-based check that matched a
|
|
50
|
+
real `Call` node is `HIGH` confidence; a line-regex check that can't fully
|
|
51
|
+
rule out a docstring or string-literal mention is `MEDIUM`; a handful of
|
|
52
|
+
inherently heuristic checks (timing-attack keyword proximity, ReDoS
|
|
53
|
+
backtracking-risk judgment, `debug=True` pattern matching) are `LOW`. Use
|
|
54
|
+
`--confidence-min medium` to cut noise from the fuzzier checks without
|
|
55
|
+
raising your severity bar.
|
|
56
|
+
|
|
57
|
+
### Baselining an existing codebase
|
|
58
|
+
|
|
59
|
+
Adopting fenceline on a codebase with existing findings doesn't mean fixing
|
|
60
|
+
all of them before CI can pass:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv run fenceline --write-baseline fenceline-baseline.json # snapshot today's findings
|
|
64
|
+
uv run fenceline --baseline fenceline-baseline.json # only new findings fail CI
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A finding is matched against the baseline by `(cwe_id, file, code_snippet)`,
|
|
68
|
+
not line number, so it keeps matching across unrelated edits that shift line
|
|
69
|
+
numbers elsewhere in the file. Re-run `--write-baseline` periodically (or
|
|
70
|
+
whenever a baselined finding is deliberately fixed) to keep it current.
|
|
71
|
+
|
|
72
|
+
### Suppressing a specific finding
|
|
73
|
+
|
|
74
|
+
`# nosec` (bare) suppresses every finding on that line; `# nosec CWE-502`
|
|
75
|
+
suppresses only that CWE, leaving any other finding on the same line intact
|
|
76
|
+
— the same convention Bandit uses, with CWE IDs instead of Bandit's own
|
|
77
|
+
B-numbers:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
pickle.loads(data) # nosec CWE-502 -- trusted internal cache, not user input
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Extending fenceline with your own checks
|
|
84
|
+
|
|
85
|
+
Built-in checks aren't dynamically discovered — they're a fixed list. Your
|
|
86
|
+
own checks are, via a `fenceline.checks` entry point in *your own*
|
|
87
|
+
`pyproject.toml`:
|
|
88
|
+
|
|
89
|
+
```toml
|
|
90
|
+
[project.entry-points."fenceline.checks"]
|
|
91
|
+
"My Custom Check (CWE-000)" = "my_package.checks:my_check_function"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The entry point's own name becomes the check's display name; the object it
|
|
95
|
+
points at must be a function with the same `(path, lines, tree) ->
|
|
96
|
+
list[Finding]` signature every built-in check has. A plugin that fails to
|
|
97
|
+
import is skipped with a warning rather than crashing the whole scan.
|
|
98
|
+
|
|
99
|
+
## Design
|
|
100
|
+
|
|
101
|
+
- `fenceline.models` — the `Finding` dataclass, severity ordering, and confidence ordering.
|
|
102
|
+
- `fenceline.config` — workspace-root discovery and the default package registry.
|
|
103
|
+
- `fenceline.ast_helpers` — shared AST/text-matching helpers used across checks.
|
|
104
|
+
- `fenceline.scanner` — file discovery and reading.
|
|
105
|
+
- `fenceline.checks` — the built-in check registry, plus `fenceline.checks`
|
|
106
|
+
entry-point discovery for third-party checks.
|
|
107
|
+
- `fenceline.checks.ast_checks` — checks that walk the parsed AST (won't match
|
|
108
|
+
a dangerous call mentioned in a docstring or string literal).
|
|
109
|
+
- `fenceline.checks.text_checks` — checks that scan raw source lines for
|
|
110
|
+
surface-syntax patterns.
|
|
111
|
+
- `fenceline.checks.manifest_checks` — checks over dependency manifests
|
|
112
|
+
(`pyproject.toml`, `requirements.txt`, etc.), not Python source.
|
|
113
|
+
- `fenceline.suppression` — `# nosec` inline-suppression parsing.
|
|
114
|
+
- `fenceline.baseline` — baseline snapshot/diff for adopting fenceline on an existing codebase.
|
|
115
|
+
- `fenceline.reporting` — text/JSON report rendering.
|
|
116
|
+
- `fenceline.cli` — argument parsing and the scan loop.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fenceline"
|
|
3
|
+
version = "1.0.2"
|
|
4
|
+
description = "Zero-day security scanner for the Boti workspace"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Luis Valverde"},
|
|
10
|
+
]
|
|
11
|
+
maintainers = [
|
|
12
|
+
{name = "Luis Valverde", email = "lvalverdeb@gmail.com"},
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Topic :: Security",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"boti>=1.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/lvalverdeb/fenceline"
|
|
29
|
+
Repository = "https://github.com/lvalverdeb/fenceline"
|
|
30
|
+
Documentation = "https://github.com/lvalverdeb/fenceline#readme"
|
|
31
|
+
Issues = "https://github.com/lvalverdeb/fenceline/issues"
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
fenceline = "fenceline.cli:main"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["setuptools>=80", "wheel"]
|
|
38
|
+
build-backend = "setuptools.build_meta"
|
|
39
|
+
|
|
40
|
+
[dependency-groups]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=9.0.3",
|
|
43
|
+
"ruff>=0.11.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
include-package-data = true
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-dir]
|
|
50
|
+
"" = "src"
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["src"]
|
|
54
|
+
include = ["fenceline*"]
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.package-data]
|
|
57
|
+
fenceline = ["py.typed"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff]
|
|
63
|
+
line-length = 100
|
|
64
|
+
target-version = "py313"
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint]
|
|
67
|
+
select = ["E", "F", "W", "I", "UP"]
|
|
68
|
+
ignore = ["E501"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Zero-day security scanner for the Boti workspace.
|
|
2
|
+
|
|
3
|
+
Maps findings to the CWE Top 25 (2025), OWASP Top 10:2025, and known Python
|
|
4
|
+
zero-day exploit patterns. See :mod:`fenceline.cli` for the CLI entry point
|
|
5
|
+
and :mod:`fenceline.checks` for the check registry.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fenceline.cli import main
|
|
11
|
+
|
|
12
|
+
__all__ = ["main"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""AST and text-matching helpers shared by the check functions in
|
|
2
|
+
:mod:`fenceline.checks`.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"_LOGGING_METHOD_NAMES",
|
|
12
|
+
"_LOG_METHOD_CALL_RE",
|
|
13
|
+
"_LOG_METHOD_CALL_ANY_RE",
|
|
14
|
+
"_LOG_NAME_TOKENS",
|
|
15
|
+
"_NESTED_SCOPE_TYPES",
|
|
16
|
+
"_call_name",
|
|
17
|
+
"_full_attr",
|
|
18
|
+
"_is_re_compile",
|
|
19
|
+
"_is_sqlalchemy_compile",
|
|
20
|
+
"_has_dynamic_arg",
|
|
21
|
+
"_node_line",
|
|
22
|
+
"_has_log_like_token",
|
|
23
|
+
"_walk_skip_nested_scopes",
|
|
24
|
+
"_handler_has_diagnostic",
|
|
25
|
+
"_skip",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# Logging method names shared by every check that needs to recognize a
|
|
29
|
+
# logging call (CWE-778 diagnostic detection, CWE-117 log injection,
|
|
30
|
+
# CWE-532 log secrets). Defined once so these checks can never independently
|
|
31
|
+
# drift out of sync with each other on which method names count as logging.
|
|
32
|
+
_LOGGING_METHOD_NAMES = frozenset(
|
|
33
|
+
{"debug", "info", "warning", "warn", "error", "critical", "exception", "log"}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Derived from _LOGGING_METHOD_NAMES (not a second hand-typed list) — matches
|
|
37
|
+
# a logging call whose argument is an f-string, e.g. `logger.error(f"...")`.
|
|
38
|
+
_LOG_METHOD_CALL_RE = re.compile(
|
|
39
|
+
r"\.(?:" + "|".join(re.escape(name) for name in _LOGGING_METHOD_NAMES) + r')\s*\(\s*f["\']'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Same method-name set, without the f-string requirement — for checks (e.g.
|
|
43
|
+
# check_log_secrets) that need to detect any logging call regardless of the
|
|
44
|
+
# argument's literal form.
|
|
45
|
+
_LOG_METHOD_CALL_ANY_RE = re.compile(
|
|
46
|
+
r"\.(?:" + "|".join(re.escape(name) for name in _LOGGING_METHOD_NAMES) + r")\s*\("
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Word-level tokens (not raw substrings) that mark a bare function call as
|
|
50
|
+
# logging-related, e.g. `_log(logger, "debug", ...)` or `log_error(...)`.
|
|
51
|
+
# Matching on whole '.'/'_'-delimited tokens rather than a raw substring test
|
|
52
|
+
# avoids false positives on unrelated names that merely end in "...log" with
|
|
53
|
+
# no delimiter — `catalog.get()`, `dialog.close()`, `backlog.append()`, and
|
|
54
|
+
# `analog_signal.process()` all contain the substring "log" but are not
|
|
55
|
+
# logging calls; none of them tokenize to a standalone "log"-family word.
|
|
56
|
+
_LOG_NAME_TOKENS = frozenset({"log", "logs", "logger", "logging"})
|
|
57
|
+
|
|
58
|
+
# AST node types that introduce a new, separately-executed scope. A statement
|
|
59
|
+
# merely *defined* inside one of these (a nested function/lambda/class body)
|
|
60
|
+
# does not run when the enclosing except block runs, so diagnostics found
|
|
61
|
+
# there don't count as the handler having one.
|
|
62
|
+
_NESTED_SCOPE_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _call_name(node: ast.Call) -> str:
|
|
66
|
+
if isinstance(node.func, ast.Name):
|
|
67
|
+
return node.func.id
|
|
68
|
+
if isinstance(node.func, ast.Attribute):
|
|
69
|
+
return node.func.attr
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _full_attr(node: ast.Call) -> str:
|
|
74
|
+
parts: list[str] = []
|
|
75
|
+
cur = node.func
|
|
76
|
+
while isinstance(cur, ast.Attribute):
|
|
77
|
+
parts.append(cur.attr)
|
|
78
|
+
cur = cur.value
|
|
79
|
+
if isinstance(cur, ast.Name):
|
|
80
|
+
parts.append(cur.id)
|
|
81
|
+
parts.reverse()
|
|
82
|
+
return ".".join(parts)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_re_compile(node: ast.Call) -> bool:
|
|
86
|
+
name = _full_attr(node)
|
|
87
|
+
return (
|
|
88
|
+
name
|
|
89
|
+
in (
|
|
90
|
+
"re.compile",
|
|
91
|
+
"re.compile",
|
|
92
|
+
)
|
|
93
|
+
or _call_name(node) == "compile"
|
|
94
|
+
and isinstance(node.func, ast.Attribute)
|
|
95
|
+
and isinstance(node.func.value, ast.Name)
|
|
96
|
+
and node.func.value.id == "re"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_sqlalchemy_compile(node: ast.Call) -> bool:
|
|
101
|
+
name = _full_attr(node)
|
|
102
|
+
return "compile" in name and any(x in name for x in ("statement", "query", "select", "sql"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _has_dynamic_arg(node: ast.Call) -> bool:
|
|
106
|
+
"""Check if any positional or keyword argument to a call is dynamic (not a constant)."""
|
|
107
|
+
for arg in node.args:
|
|
108
|
+
if not isinstance(arg, (ast.Constant, ast.Str, ast.Num, ast.NameConstant)):
|
|
109
|
+
return True
|
|
110
|
+
for kw in node.keywords:
|
|
111
|
+
if not isinstance(kw.value, (ast.Constant, ast.Str, ast.Num, ast.NameConstant)):
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _node_line(node: ast.AST) -> int:
|
|
117
|
+
return getattr(node, "lineno", 0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _has_log_like_token(full_name: str) -> bool:
|
|
121
|
+
"""True if any ``.``/``_``-delimited token in *full_name* is a
|
|
122
|
+
logging-related word, e.g. ``"log"`` or ``"logger"`` — not merely present
|
|
123
|
+
as a substring somewhere inside a longer, unrelated word.
|
|
124
|
+
"""
|
|
125
|
+
tokens = re.split(r"[._]+", full_name.lower())
|
|
126
|
+
return any(tok in _LOG_NAME_TOKENS for tok in tokens if tok)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _walk_skip_nested_scopes(node: ast.AST):
|
|
130
|
+
"""Like ``ast.walk()``, but does not descend into nested function/lambda/
|
|
131
|
+
class bodies — see ``_NESTED_SCOPE_TYPES`` for why.
|
|
132
|
+
|
|
133
|
+
Checks *node itself* (not just its children) against ``_NESTED_SCOPE_TYPES``
|
|
134
|
+
before recursing, so this is correct both when a scope-creating node is
|
|
135
|
+
reached via recursion from a wrapping statement (e.g. a ``Lambda`` inside
|
|
136
|
+
an ``Assign``) and when it *is* the starting node passed in directly (e.g.
|
|
137
|
+
a top-level ``def`` statement in the handler body being walked itself).
|
|
138
|
+
"""
|
|
139
|
+
yield node
|
|
140
|
+
if isinstance(node, _NESTED_SCOPE_TYPES):
|
|
141
|
+
return
|
|
142
|
+
for child in ast.iter_child_nodes(node):
|
|
143
|
+
yield from _walk_skip_nested_scopes(child)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _handler_has_diagnostic(handler: ast.ExceptHandler) -> bool:
|
|
147
|
+
"""True if an ``except`` block logs, re-raises, or otherwise surfaces the
|
|
148
|
+
exception rather than silently swallowing it.
|
|
149
|
+
|
|
150
|
+
Looks for: a call whose attribute name is a logging method (``logger.debug(...)``,
|
|
151
|
+
``self.logger.error(...)``); a bare function call whose name tokenizes to a
|
|
152
|
+
logging-related word (catches helper wrappers like ``_log(logger, "debug", ...)``
|
|
153
|
+
without false-positiving on unrelated names like ``catalog``/``dialog``); ``warnings.warn(...)``;
|
|
154
|
+
or a ``raise`` statement anywhere in the block (including nested ``if``/``try``,
|
|
155
|
+
but not inside a nested function/lambda/class body — code merely defined there
|
|
156
|
+
doesn't run as part of this handler). Walks the whole subtree, not just
|
|
157
|
+
top-level statements, so a diagnostic inside a nested block still counts.
|
|
158
|
+
"""
|
|
159
|
+
for stmt in handler.body:
|
|
160
|
+
for node in _walk_skip_nested_scopes(stmt):
|
|
161
|
+
if isinstance(node, ast.Raise):
|
|
162
|
+
return True
|
|
163
|
+
if not isinstance(node, ast.Call):
|
|
164
|
+
continue
|
|
165
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in _LOGGING_METHOD_NAMES:
|
|
166
|
+
return True
|
|
167
|
+
full = _full_attr(node)
|
|
168
|
+
if full == "warnings.warn" or _has_log_like_token(full):
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _skip(line: str) -> bool:
|
|
174
|
+
return line.strip().startswith("#")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Baseline support: snapshot current findings so a later scan only reports
|
|
2
|
+
and fails on *new* findings, letting an existing codebase adopt fenceline
|
|
3
|
+
without having to fix every pre-existing finding on day one.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fenceline.models import Finding
|
|
12
|
+
|
|
13
|
+
__all__ = ["write_baseline", "load_baseline", "split_by_baseline"]
|
|
14
|
+
|
|
15
|
+
Fingerprint = tuple[str, str, str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fingerprint(f: Finding) -> Fingerprint:
|
|
19
|
+
# (cwe_id, file, code_snippet) -- deliberately not line number, so the
|
|
20
|
+
# baseline still matches after unrelated edits elsewhere in the file
|
|
21
|
+
# shift line numbers.
|
|
22
|
+
return (f.cwe_id, f.file, f.code_snippet)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def write_baseline(findings: list[Finding], path: Path) -> None:
|
|
26
|
+
fingerprints = sorted({_fingerprint(f) for f in findings})
|
|
27
|
+
data = {"fingerprints": [list(fp) for fp in fingerprints]}
|
|
28
|
+
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_baseline(path: Path) -> set[Fingerprint]:
|
|
32
|
+
data = json.loads(path.read_text())
|
|
33
|
+
return {tuple(fp) for fp in data.get("fingerprints", [])}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def split_by_baseline(
|
|
37
|
+
findings: list[Finding], baseline: set[Fingerprint]
|
|
38
|
+
) -> tuple[list[Finding], int]:
|
|
39
|
+
"""Returns ``(new_findings, suppressed_count)`` -- a finding whose
|
|
40
|
+
fingerprint is already in *baseline* is pre-existing debt, not new."""
|
|
41
|
+
new: list[Finding] = []
|
|
42
|
+
suppressed = 0
|
|
43
|
+
for f in findings:
|
|
44
|
+
if _fingerprint(f) in baseline:
|
|
45
|
+
suppressed += 1
|
|
46
|
+
else:
|
|
47
|
+
new.append(f)
|
|
48
|
+
return new, suppressed
|