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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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