fenceline 1.0.2__py3-none-any.whl
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/__init__.py +12 -0
- fenceline/ast_helpers.py +174 -0
- fenceline/baseline.py +48 -0
- fenceline/checks/__init__.py +177 -0
- fenceline/checks/ast_checks.py +722 -0
- fenceline/checks/manifest_checks.py +148 -0
- fenceline/checks/text_checks.py +1360 -0
- fenceline/cli.py +194 -0
- fenceline/config.py +72 -0
- fenceline/models.py +32 -0
- fenceline/py.typed +0 -0
- fenceline/reporting.py +191 -0
- fenceline/scanner.py +67 -0
- fenceline/suppression.py +43 -0
- fenceline-1.0.2.dist-info/METADATA +138 -0
- fenceline-1.0.2.dist-info/RECORD +19 -0
- fenceline-1.0.2.dist-info/WHEEL +5 -0
- fenceline-1.0.2.dist-info/entry_points.txt +2 -0
- fenceline-1.0.2.dist-info/top_level.txt +1 -0
fenceline/__init__.py
ADDED
|
@@ -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"]
|
fenceline/ast_helpers.py
ADDED
|
@@ -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("#")
|
fenceline/baseline.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Aggregates every check function into a single ordered CHECKS registry.
|
|
2
|
+
|
|
3
|
+
Each entry is ``(display_name, check_fn)``; ``check_fn`` receives
|
|
4
|
+
``(path, lines, tree)`` and returns ``list[Finding]``. See
|
|
5
|
+
:mod:`fenceline.checks.ast_checks`, :mod:`fenceline.checks.text_checks`,
|
|
6
|
+
and :mod:`fenceline.checks.manifest_checks` for the implementations.
|
|
7
|
+
|
|
8
|
+
Third-party packages can register additional checks without touching this
|
|
9
|
+
file, by declaring an entry point in the ``fenceline.checks`` group in their
|
|
10
|
+
own ``pyproject.toml``::
|
|
11
|
+
|
|
12
|
+
[project.entry-points."fenceline.checks"]
|
|
13
|
+
"My Custom Check (CWE-000)" = "my_package.checks:my_check_function"
|
|
14
|
+
|
|
15
|
+
The entry point's own name is used as the check's display name; the object
|
|
16
|
+
it loads must be a check function with the same ``(path, lines, tree) ->
|
|
17
|
+
list[Finding]`` signature every built-in check has. Built-in checks are
|
|
18
|
+
*not* themselves routed through entry points -- there's no benefit to
|
|
19
|
+
indirecting checks fenceline ships with itself through a discovery
|
|
20
|
+
mechanism meant for external extension, the same way pytest's own built-in
|
|
21
|
+
behavior isn't implemented as a "plugin" of itself even though third-party
|
|
22
|
+
pytest plugins use exactly that mechanism.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import sys
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from importlib.metadata import entry_points
|
|
30
|
+
|
|
31
|
+
from fenceline.checks.ast_checks import (
|
|
32
|
+
check_decode_exec_chains,
|
|
33
|
+
check_eval_exec,
|
|
34
|
+
check_except_pass,
|
|
35
|
+
check_format_string,
|
|
36
|
+
check_huggingface_unsafe_download,
|
|
37
|
+
check_insecure_default,
|
|
38
|
+
check_insecure_random,
|
|
39
|
+
check_insufficient_logging,
|
|
40
|
+
check_numpy_load,
|
|
41
|
+
check_pandas_eval,
|
|
42
|
+
check_parquet_arrow_deserialize,
|
|
43
|
+
check_request_timeout,
|
|
44
|
+
check_torch_load,
|
|
45
|
+
)
|
|
46
|
+
from fenceline.checks.manifest_checks import (
|
|
47
|
+
check_dependency_cve,
|
|
48
|
+
check_unbounded_pins,
|
|
49
|
+
)
|
|
50
|
+
from fenceline.checks.text_checks import (
|
|
51
|
+
check_arbitrary_write,
|
|
52
|
+
check_assert_security,
|
|
53
|
+
check_bind_all_interfaces,
|
|
54
|
+
check_command_injection,
|
|
55
|
+
check_crlf,
|
|
56
|
+
check_debug_mode,
|
|
57
|
+
check_exec_driver_sql,
|
|
58
|
+
check_hardcoded_secrets,
|
|
59
|
+
check_hardcoded_tokens,
|
|
60
|
+
check_ldap,
|
|
61
|
+
check_legacy_pycrypto,
|
|
62
|
+
check_log_injection,
|
|
63
|
+
check_log_secrets,
|
|
64
|
+
check_model_file_load,
|
|
65
|
+
check_null_byte,
|
|
66
|
+
check_numpy_load_lib,
|
|
67
|
+
check_open_redirect,
|
|
68
|
+
check_pandas_pickle,
|
|
69
|
+
check_pandas_xml_xxe,
|
|
70
|
+
check_path_traversal,
|
|
71
|
+
check_pickle,
|
|
72
|
+
check_pth_startup_hooks,
|
|
73
|
+
check_redos,
|
|
74
|
+
check_resource_limits,
|
|
75
|
+
check_sensitive_exposure,
|
|
76
|
+
check_sql_injection,
|
|
77
|
+
check_ssh_host_key,
|
|
78
|
+
check_ssrf,
|
|
79
|
+
check_ssti,
|
|
80
|
+
check_supply_chain,
|
|
81
|
+
check_symlink,
|
|
82
|
+
check_tempfile,
|
|
83
|
+
check_timing_attack,
|
|
84
|
+
check_tls_verify,
|
|
85
|
+
check_trojan_source,
|
|
86
|
+
check_weak_crypto,
|
|
87
|
+
check_weak_hash,
|
|
88
|
+
check_weak_tls_version,
|
|
89
|
+
check_xxe,
|
|
90
|
+
check_yaml_deserialize,
|
|
91
|
+
check_zipslip,
|
|
92
|
+
)
|
|
93
|
+
from fenceline.models import Finding
|
|
94
|
+
|
|
95
|
+
__all__ = ["CHECKS"]
|
|
96
|
+
|
|
97
|
+
CheckFn = Callable[..., list[Finding]]
|
|
98
|
+
|
|
99
|
+
_BUILTIN_CHECKS: list[tuple[str, CheckFn]] = [
|
|
100
|
+
("Pickle Deserialization (CWE-502)", check_pickle),
|
|
101
|
+
("eval/exec/compile Injection (CWE-94)", check_eval_exec),
|
|
102
|
+
("Command Injection (CWE-78)", check_command_injection),
|
|
103
|
+
("SQL Injection (CWE-89)", check_sql_injection),
|
|
104
|
+
("Path Traversal (CWE-22)", check_path_traversal),
|
|
105
|
+
("Hardcoded Secrets (CWE-798)", check_hardcoded_secrets),
|
|
106
|
+
("YAML Unsafe Deserialization (CWE-502)", check_yaml_deserialize),
|
|
107
|
+
("XXE (CWE-611)", check_xxe),
|
|
108
|
+
("SSRF (CWE-918)", check_ssrf),
|
|
109
|
+
("Insecure Temp File (CWE-377)", check_tempfile),
|
|
110
|
+
("Symlink Following (CWE-61)", check_symlink),
|
|
111
|
+
("ReDoS (CWE-1333)", check_redos),
|
|
112
|
+
("Assert Security (CWE-617/670)", check_assert_security),
|
|
113
|
+
("exec_driver_sql Safety (CWE-89)", check_exec_driver_sql),
|
|
114
|
+
("Supply Chain Dep Confusion (CWE-1104)", check_supply_chain),
|
|
115
|
+
("Active Debug Code (CWE-489)", check_debug_mode),
|
|
116
|
+
("Insufficient Logging (CWE-778)", check_insufficient_logging),
|
|
117
|
+
("Timing Attack (CWE-208)", check_timing_attack),
|
|
118
|
+
("Sensitive Exposure (CWE-200)", check_sensitive_exposure),
|
|
119
|
+
("NUL Byte Injection (CWE-158)", check_null_byte),
|
|
120
|
+
("Resource Limits (CWE-770)", check_resource_limits),
|
|
121
|
+
("Insecure Random (CWE-338)", check_insecure_random),
|
|
122
|
+
("SSTI (CWE-1336)", check_ssti),
|
|
123
|
+
("CRLF Injection (CWE-93)", check_crlf),
|
|
124
|
+
("LDAP Injection (CWE-90)", check_ldap),
|
|
125
|
+
("Weak Hash (CWE-328)", check_weak_hash),
|
|
126
|
+
("Open Redirect (CWE-601)", check_open_redirect),
|
|
127
|
+
("Log Injection (CWE-117)", check_log_injection),
|
|
128
|
+
("Format String (CWE-134)", check_format_string),
|
|
129
|
+
("Arbitrary File Write (CWE-73)", check_arbitrary_write),
|
|
130
|
+
("Insecure Default (CWE-453)", check_insecure_default),
|
|
131
|
+
("Log Secrets (CWE-532)", check_log_secrets),
|
|
132
|
+
("Disabled TLS Verify (CWE-295)", check_tls_verify),
|
|
133
|
+
("ZipSlip / TarSlip (CWE-22)", check_zipslip),
|
|
134
|
+
("Hardcoded API Keys (CWE-798)", check_hardcoded_tokens),
|
|
135
|
+
("Weak Crypto (CWE-327)", check_weak_crypto),
|
|
136
|
+
("Dependency CVE Scan (CWE-1104)", check_dependency_cve),
|
|
137
|
+
("Trojan Source (CWE-1007)", check_trojan_source),
|
|
138
|
+
("HTTP Request Timeout (CWE-1088)", check_request_timeout),
|
|
139
|
+
("torch.load weights_only (CWE-502)", check_torch_load),
|
|
140
|
+
("SSH Host Key Verification (CWE-322)", check_ssh_host_key),
|
|
141
|
+
("except:pass/continue (CWE-391)", check_except_pass),
|
|
142
|
+
("pandas eval/query Code Injection (CWE-94)", check_pandas_eval),
|
|
143
|
+
("numpy.load allow_pickle (CWE-502)", check_numpy_load),
|
|
144
|
+
("pandas.read_pickle (CWE-502)", check_pandas_pickle),
|
|
145
|
+
("numpy.load library injection (CWE-114)", check_numpy_load_lib),
|
|
146
|
+
("pandas.read_xml XXE (CWE-611)", check_pandas_xml_xxe),
|
|
147
|
+
(".pth Startup Hooks (CWE-829 / T1546.018)", check_pth_startup_hooks),
|
|
148
|
+
("Parquet Arrow Deserialization (CWE-502 / CVE-2026-41486)", check_parquet_arrow_deserialize),
|
|
149
|
+
("Unbounded Dependency Pins (CWE-1104)", check_unbounded_pins),
|
|
150
|
+
("ML Model File Loading (OWASP ML06 / CWE-502)", check_model_file_load),
|
|
151
|
+
("Decode-then-Execute Chains (CWE-94)", check_decode_exec_chains),
|
|
152
|
+
("Binding to All Interfaces (CWE-1327)", check_bind_all_interfaces),
|
|
153
|
+
("Weak TLS Protocol Version (CWE-326)", check_weak_tls_version),
|
|
154
|
+
("Legacy PyCrypto Import (CWE-1104)", check_legacy_pycrypto),
|
|
155
|
+
("HuggingFace Unsafe Download (OWASP ML06 / B615)", check_huggingface_unsafe_download),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
_PLUGIN_GROUP = "fenceline.checks"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _discover_plugin_checks() -> list[tuple[str, CheckFn]]:
|
|
162
|
+
"""Loads third-party checks registered under the ``fenceline.checks``
|
|
163
|
+
entry-point group. A plugin that fails to import is skipped with a
|
|
164
|
+
warning rather than crashing the whole tool -- one broken third-party
|
|
165
|
+
package shouldn't take down scanning for everyone else."""
|
|
166
|
+
discovered: list[tuple[str, CheckFn]] = []
|
|
167
|
+
for ep in entry_points(group=_PLUGIN_GROUP):
|
|
168
|
+
try:
|
|
169
|
+
check_fn = ep.load()
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
print(f" ⚠ fenceline: failed to load plugin check {ep.name!r}: {exc}", file=sys.stderr)
|
|
172
|
+
continue
|
|
173
|
+
discovered.append((ep.name, check_fn))
|
|
174
|
+
return discovered
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
CHECKS: list[tuple[str, CheckFn]] = _BUILTIN_CHECKS + _discover_plugin_checks()
|