sourcecode 2.7.0__py3-none-any.whl → 3.1.0__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.
Potentially problematic release.
This version of sourcecode might be problematic. Click here for more details.
- sourcecode/__init__.py +1 -1
- sourcecode/cache.py +44 -3
- sourcecode/chain_rules.py +289 -0
- sourcecode/cli.py +497 -33
- sourcecode/context_cache.py +9 -0
- sourcecode/contract_pipeline.py +14 -3
- sourcecode/detectors/java.py +7 -1
- sourcecode/envelope.py +193 -0
- sourcecode/format_contract.py +11 -5
- sourcecode/git_analyzer.py +74 -12
- sourcecode/parse_cache.py +24 -5
- sourcecode/posture.py +800 -0
- sourcecode/prepare_context.py +126 -35
- sourcecode/repository_ir.py +145 -16
- sourcecode/schemas/envelope-v1.schema.json +74 -0
- sourcecode/semantic_integration_engine.py +11 -0
- sourcecode/spring_impact.py +44 -10
- sourcecode/spring_profiles.py +11 -1
- sourcecode/spring_properties.py +217 -0
- sourcecode/spring_security_audit.py +5 -3
- sourcecode/verify_repo.py +252 -0
- sourcecode/verify_rules.py +135 -12
- {sourcecode-2.7.0.dist-info → sourcecode-3.1.0.dist-info}/METADATA +3 -1
- {sourcecode-2.7.0.dist-info → sourcecode-3.1.0.dist-info}/RECORD +27 -21
- {sourcecode-2.7.0.dist-info → sourcecode-3.1.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.7.0.dist-info → sourcecode-3.1.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.7.0.dist-info → sourcecode-3.1.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cache.py
CHANGED
|
@@ -75,11 +75,52 @@ SCHEMA_VERSION: str = "2"
|
|
|
75
75
|
#: Bump to invalidate all L1 core caches (independent of snapshot version).
|
|
76
76
|
CORE_SCHEMA_VERSION: str = "1"
|
|
77
77
|
|
|
78
|
-
#:
|
|
79
|
-
#:
|
|
80
|
-
#:
|
|
78
|
+
#: Manual override for invalidating all L1 core caches. Rarely needed now that the
|
|
79
|
+
#: key carries :func:`analyzer_fingerprint`; kept as an escape hatch for cases the
|
|
80
|
+
#: fingerprint cannot see (a changed dependency, a corrected cache format).
|
|
81
|
+
#: Package version bumps must NOT bump this value.
|
|
81
82
|
ANALYZER_CACHE_VERSION: str = "1"
|
|
82
83
|
|
|
84
|
+
|
|
85
|
+
def analyzer_fingerprint() -> str:
|
|
86
|
+
"""Content hash of the analysis code, memoised for the process.
|
|
87
|
+
|
|
88
|
+
A cache key built only from the repository state and a hand-maintained version
|
|
89
|
+
constant cannot notice that the analyzer itself changed. That discipline failed
|
|
90
|
+
exactly as designed-by-humans disciplines do: five consecutive fixes to analysis
|
|
91
|
+
logic shipped without anyone bumping ``ANALYZER_CACHE_VERSION``, so an upgraded
|
|
92
|
+
install kept serving pre-fix results from cache on an unchanged commit — the
|
|
93
|
+
fixes looked inert until the user cleared the cache by hand.
|
|
94
|
+
|
|
95
|
+
Hashing file *content* (not mtime) keeps the key stable across reinstalls and
|
|
96
|
+
identical checkouts on different machines, so caches stay shareable. Any edit to
|
|
97
|
+
any module invalidates every entry: over-invalidation costs a re-analysis,
|
|
98
|
+
under-invalidation reports facts the current code would not produce.
|
|
99
|
+
|
|
100
|
+
Falls back to the package version when the source is unreadable (zipimport,
|
|
101
|
+
frozen builds) — degraded, never wrong-by-silence.
|
|
102
|
+
"""
|
|
103
|
+
global _ANALYZER_FINGERPRINT
|
|
104
|
+
if _ANALYZER_FINGERPRINT is not None:
|
|
105
|
+
return _ANALYZER_FINGERPRINT
|
|
106
|
+
digest = hashlib.sha256()
|
|
107
|
+
try:
|
|
108
|
+
pkg_root = Path(__file__).resolve().parent
|
|
109
|
+
sources = sorted(pkg_root.rglob("*.py"))
|
|
110
|
+
if not sources:
|
|
111
|
+
raise OSError("no analyzer sources found")
|
|
112
|
+
for src in sources:
|
|
113
|
+
digest.update(src.relative_to(pkg_root).as_posix().encode("utf-8"))
|
|
114
|
+
digest.update(src.read_bytes())
|
|
115
|
+
_ANALYZER_FINGERPRINT = digest.hexdigest()[:12]
|
|
116
|
+
except Exception:
|
|
117
|
+
from sourcecode import __version__ as _pkg_version
|
|
118
|
+
_ANALYZER_FINGERPRINT = f"pkg-{_pkg_version}"
|
|
119
|
+
return _ANALYZER_FINGERPRINT
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_ANALYZER_FINGERPRINT: Optional[str] = None
|
|
123
|
+
|
|
83
124
|
#: Fields eligible for CAS deduplication (applied to top-level JSON dict keys).
|
|
84
125
|
_CAS_FIELDS: frozenset[str] = frozenset([
|
|
85
126
|
"file_paths",
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""chain_rules.py — the path rules a security filter chain declares.
|
|
2
|
+
|
|
3
|
+
`posture` could say *which* chain configuration a profile activates, never what
|
|
4
|
+
that chain permits. "Under `prod` the security configuration is active" is a fact
|
|
5
|
+
about a bean; "under `prod`, `POST /admin/**` requires ROLE_ADMIN and `/health` is
|
|
6
|
+
open to anyone" is a fact about the request, and only the second one is what a
|
|
7
|
+
reviewer is asking.
|
|
8
|
+
|
|
9
|
+
This is an EXTRACTOR: it reads Java source for the published `HttpSecurity`
|
|
10
|
+
authorization DSL (`requestMatchers` / `antMatchers` / `mvcMatchers` /
|
|
11
|
+
`anyRequest`, followed by the access method that decides them) and returns the
|
|
12
|
+
rules **in declaration order**, because Spring applies the first one that matches
|
|
13
|
+
and any other order would answer a different question.
|
|
14
|
+
|
|
15
|
+
What it deliberately does not do: evaluate `access(...)` expressions, follow
|
|
16
|
+
matchers built by a helper method, or read matchers passed as constructed
|
|
17
|
+
objects. Those are recorded as rules whose decision is *not evaluated*, so the
|
|
18
|
+
caller reports the endpoint undecided rather than silently open.
|
|
19
|
+
|
|
20
|
+
VAI: only the published Spring Security DSL vocabulary appears. Path patterns and
|
|
21
|
+
role names are DATA — carried as evidence, never branched on.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
#: Published matcher methods that introduce a rule.
|
|
31
|
+
_MATCHER_METHODS = ("requestMatchers", "antMatchers", "mvcMatchers", "regexMatchers")
|
|
32
|
+
_ANY_REQUEST = "anyRequest"
|
|
33
|
+
|
|
34
|
+
#: Published access methods → the decision they express. `access`/`hasIpAddress`
|
|
35
|
+
#: are listed so the rule is SEEN; their verdict is that they were not evaluated.
|
|
36
|
+
_ACCESS_DECISIONS = {
|
|
37
|
+
"permitAll": "permit_all",
|
|
38
|
+
"denyAll": "deny_all",
|
|
39
|
+
"authenticated": "authenticated",
|
|
40
|
+
"fullyAuthenticated": "authenticated",
|
|
41
|
+
"rememberMe": "authenticated",
|
|
42
|
+
"anonymous": "anonymous",
|
|
43
|
+
"hasRole": "role_required",
|
|
44
|
+
"hasAnyRole": "role_required",
|
|
45
|
+
"hasAuthority": "role_required",
|
|
46
|
+
"hasAnyAuthority": "role_required",
|
|
47
|
+
"access": "not_evaluated",
|
|
48
|
+
"hasIpAddress": "not_evaluated",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_QUOTED_RE = re.compile(r'"([^"]*)"')
|
|
52
|
+
_HTTP_METHOD_RE = re.compile(r"\bHttpMethod\s*\.\s*([A-Z]+)\b")
|
|
53
|
+
_MATCHER_CALL_RE = re.compile(
|
|
54
|
+
r"\.\s*(" + "|".join(_MATCHER_METHODS + (_ANY_REQUEST,)) + r")\s*\("
|
|
55
|
+
)
|
|
56
|
+
_ACCESS_CALL_RE = re.compile(
|
|
57
|
+
r"\s*\.\s*(" + "|".join(_ACCESS_DECISIONS) + r")\s*\("
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class AccessRule:
|
|
63
|
+
"""One `matcher → access` pair, positioned in the order Spring applies it."""
|
|
64
|
+
|
|
65
|
+
patterns: "tuple[str, ...]" # empty → anyRequest (matches everything)
|
|
66
|
+
methods: "tuple[str, ...]" # empty → any HTTP method
|
|
67
|
+
decision: str
|
|
68
|
+
authorities: "tuple[str, ...]" = ()
|
|
69
|
+
source_file: str = ""
|
|
70
|
+
line: int = 0
|
|
71
|
+
order: int = 0
|
|
72
|
+
matcher: str = "" # the DSL method that declared it
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_any_request(self) -> bool:
|
|
76
|
+
"""`anyRequest()` — the catch-all, and the only patternless rule that
|
|
77
|
+
Spring resolves. A matcher call with no readable pattern is a different
|
|
78
|
+
thing entirely: see `paths_unknown`."""
|
|
79
|
+
return self.matcher == _ANY_REQUEST
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def paths_unknown(self) -> bool:
|
|
83
|
+
"""A matcher whose paths this parser could not read. It may cover any
|
|
84
|
+
request, so nothing after it can be concluded from the rules alone."""
|
|
85
|
+
return self.matcher != _ANY_REQUEST and not self.patterns
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
out: dict = {
|
|
89
|
+
"matcher": self.matcher,
|
|
90
|
+
"patterns": (
|
|
91
|
+
["**"] if self.is_any_request
|
|
92
|
+
else (list(self.patterns) or ["(paths this parser could not read)"])
|
|
93
|
+
),
|
|
94
|
+
"decision": self.decision,
|
|
95
|
+
"source_file": self.source_file,
|
|
96
|
+
"line": self.line,
|
|
97
|
+
}
|
|
98
|
+
if self.methods:
|
|
99
|
+
out["methods"] = list(self.methods)
|
|
100
|
+
if self.authorities:
|
|
101
|
+
out["authorities"] = list(self.authorities)
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _balanced(text: str, open_index: int) -> "tuple[str, int]":
|
|
106
|
+
"""(body, index after ')') for the parenthesis opening at `open_index`.
|
|
107
|
+
|
|
108
|
+
String literals are respected so a `)` inside a pattern does not close the
|
|
109
|
+
call early. Returns ("", -1) when the call is not balanced in this file.
|
|
110
|
+
"""
|
|
111
|
+
depth = 0
|
|
112
|
+
i = open_index
|
|
113
|
+
in_string = False
|
|
114
|
+
escaped = False
|
|
115
|
+
while i < len(text):
|
|
116
|
+
char = text[i]
|
|
117
|
+
if in_string:
|
|
118
|
+
if escaped:
|
|
119
|
+
escaped = False
|
|
120
|
+
elif char == "\\":
|
|
121
|
+
escaped = True
|
|
122
|
+
elif char == '"':
|
|
123
|
+
in_string = False
|
|
124
|
+
elif char == '"':
|
|
125
|
+
in_string = True
|
|
126
|
+
elif char == "(":
|
|
127
|
+
depth += 1
|
|
128
|
+
elif char == ")":
|
|
129
|
+
depth -= 1
|
|
130
|
+
if depth == 0:
|
|
131
|
+
return text[open_index + 1:i], i + 1
|
|
132
|
+
i += 1
|
|
133
|
+
return "", -1
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def rules_from_source(source: str, source_file: str = "") -> "list[AccessRule]":
|
|
137
|
+
"""Every authorization rule in one file, in declaration order."""
|
|
138
|
+
rules: list[AccessRule] = []
|
|
139
|
+
position = 0
|
|
140
|
+
while True:
|
|
141
|
+
match = _MATCHER_CALL_RE.search(source, position)
|
|
142
|
+
if match is None:
|
|
143
|
+
break
|
|
144
|
+
matcher = match.group(1)
|
|
145
|
+
body, after = _balanced(source, match.end() - 1)
|
|
146
|
+
if after < 0:
|
|
147
|
+
break
|
|
148
|
+
access = _ACCESS_CALL_RE.match(source, after)
|
|
149
|
+
if access is None:
|
|
150
|
+
# A matcher whose access method is not adjacent (chained through a
|
|
151
|
+
# variable, or a form this parser does not read). Recorded so the
|
|
152
|
+
# caller knows the path is governed by something it could not read.
|
|
153
|
+
position = after
|
|
154
|
+
patterns = tuple(p for p in _QUOTED_RE.findall(body) if p)
|
|
155
|
+
if matcher != _ANY_REQUEST and not patterns:
|
|
156
|
+
continue
|
|
157
|
+
rules.append(AccessRule(
|
|
158
|
+
patterns=() if matcher == _ANY_REQUEST else patterns,
|
|
159
|
+
methods=tuple(sorted(set(_HTTP_METHOD_RE.findall(body)))),
|
|
160
|
+
decision="not_evaluated",
|
|
161
|
+
source_file=source_file,
|
|
162
|
+
line=source.count("\n", 0, match.start()) + 1,
|
|
163
|
+
order=len(rules),
|
|
164
|
+
matcher=matcher,
|
|
165
|
+
))
|
|
166
|
+
continue
|
|
167
|
+
method_name = access.group(1)
|
|
168
|
+
access_body, access_end = _balanced(source, access.end() - 1)
|
|
169
|
+
position = access_end if access_end > 0 else after
|
|
170
|
+
patterns = tuple(p for p in _QUOTED_RE.findall(body) if p)
|
|
171
|
+
if matcher != _ANY_REQUEST and not patterns:
|
|
172
|
+
# `requestMatchers(new AntPathRequestMatcher(...))` and friends: the
|
|
173
|
+
# rule exists, its paths are not literals in this call.
|
|
174
|
+
rules.append(AccessRule(
|
|
175
|
+
patterns=(), methods=(), decision="not_evaluated",
|
|
176
|
+
source_file=source_file,
|
|
177
|
+
line=source.count("\n", 0, match.start()) + 1,
|
|
178
|
+
order=len(rules), matcher=matcher,
|
|
179
|
+
))
|
|
180
|
+
continue
|
|
181
|
+
rules.append(AccessRule(
|
|
182
|
+
patterns=() if matcher == _ANY_REQUEST else patterns,
|
|
183
|
+
methods=tuple(sorted(set(_HTTP_METHOD_RE.findall(body)))),
|
|
184
|
+
decision=_ACCESS_DECISIONS[method_name],
|
|
185
|
+
authorities=tuple(_QUOTED_RE.findall(access_body)),
|
|
186
|
+
source_file=source_file,
|
|
187
|
+
line=source.count("\n", 0, match.start()) + 1,
|
|
188
|
+
order=len(rules),
|
|
189
|
+
matcher=matcher,
|
|
190
|
+
))
|
|
191
|
+
return rules
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def extract_chain_rules(
|
|
195
|
+
root: Path, java_files: "Optional[list[str]]" = None
|
|
196
|
+
) -> "dict[str, list[AccessRule]]":
|
|
197
|
+
"""`{repo-relative file: rules in declaration order}`. Never raises."""
|
|
198
|
+
root = Path(root)
|
|
199
|
+
if java_files is None:
|
|
200
|
+
try:
|
|
201
|
+
from sourcecode.repository_ir import find_java_files
|
|
202
|
+
|
|
203
|
+
java_files = find_java_files(root)
|
|
204
|
+
except Exception:
|
|
205
|
+
java_files = []
|
|
206
|
+
out: dict[str, list[AccessRule]] = {}
|
|
207
|
+
for rel in java_files or []:
|
|
208
|
+
try:
|
|
209
|
+
source = (root / rel).read_text(encoding="utf-8", errors="replace")
|
|
210
|
+
except OSError:
|
|
211
|
+
continue
|
|
212
|
+
# Cheap pre-check: the DSL always names one of these methods.
|
|
213
|
+
if not any(name in source for name in _MATCHER_METHODS + (_ANY_REQUEST,)):
|
|
214
|
+
continue
|
|
215
|
+
rules = rules_from_source(source, rel)
|
|
216
|
+
if rules:
|
|
217
|
+
out[rel] = rules
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def ant_matches(pattern: str, path: str) -> bool:
|
|
222
|
+
"""Spring Ant path semantics: `?` one char, `*` one segment, `**` many.
|
|
223
|
+
|
|
224
|
+
A trailing `/**` also matches the base path itself, as Spring's matcher does
|
|
225
|
+
(`/admin/**` covers `/admin`).
|
|
226
|
+
"""
|
|
227
|
+
if not pattern or not path:
|
|
228
|
+
return False
|
|
229
|
+
if pattern == path:
|
|
230
|
+
return True
|
|
231
|
+
regex = _ant_regex(pattern)
|
|
232
|
+
if regex.match(path):
|
|
233
|
+
return True
|
|
234
|
+
if pattern.endswith("/**"):
|
|
235
|
+
return _ant_regex(pattern[:-3] or "/").match(path) is not None
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
_ANT_CACHE: "dict[str, re.Pattern]" = {}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _ant_regex(pattern: str) -> "re.Pattern":
|
|
243
|
+
cached = _ANT_CACHE.get(pattern)
|
|
244
|
+
if cached is not None:
|
|
245
|
+
return cached
|
|
246
|
+
out: list[str] = ["^"]
|
|
247
|
+
i = 0
|
|
248
|
+
while i < len(pattern):
|
|
249
|
+
char = pattern[i]
|
|
250
|
+
if pattern.startswith("**", i):
|
|
251
|
+
out.append(".*")
|
|
252
|
+
i += 2
|
|
253
|
+
elif char == "*":
|
|
254
|
+
out.append("[^/]*")
|
|
255
|
+
i += 1
|
|
256
|
+
elif char == "?":
|
|
257
|
+
out.append("[^/]")
|
|
258
|
+
i += 1
|
|
259
|
+
else:
|
|
260
|
+
out.append(re.escape(char))
|
|
261
|
+
i += 1
|
|
262
|
+
out.append("$")
|
|
263
|
+
compiled = re.compile("".join(out))
|
|
264
|
+
_ANT_CACHE[pattern] = compiled
|
|
265
|
+
return compiled
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def rule_matches(rule: AccessRule, method: str, path: str) -> bool:
|
|
269
|
+
"""Does this rule govern `method path`? Method-less rules cover every method."""
|
|
270
|
+
if rule.methods and method and method.upper() not in rule.methods:
|
|
271
|
+
return False
|
|
272
|
+
if rule.is_any_request:
|
|
273
|
+
return True
|
|
274
|
+
return any(ant_matches(pattern, path) for pattern in rule.patterns)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def first_matching_rule(
|
|
278
|
+
rules: "list[AccessRule]", method: str, path: str
|
|
279
|
+
) -> "Optional[AccessRule]":
|
|
280
|
+
"""The rule Spring would apply: the first declared one that matches."""
|
|
281
|
+
for rule in rules:
|
|
282
|
+
if rule.paths_unknown:
|
|
283
|
+
# A rule whose paths could not be read may cover this request, and
|
|
284
|
+
# everything after it is only reachable if it does not. Stopping here
|
|
285
|
+
# is what keeps a later `permitAll` from being reported as the answer.
|
|
286
|
+
return rule
|
|
287
|
+
if rule_matches(rule, method, path):
|
|
288
|
+
return rule
|
|
289
|
+
return None
|