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 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"]
@@ -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()