sourcecode 4.2.0__py3-none-any.whl → 4.3.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.
- sourcecode/__init__.py +1 -1
- sourcecode/architectural_baseline.py +19 -0
- sourcecode/baseline_autocapture.py +375 -0
- sourcecode/cache_model.py +19 -0
- sourcecode/cli.py +552 -23
- sourcecode/client_calls.py +305 -0
- sourcecode/context_graph.py +11 -0
- sourcecode/data_exposure.py +353 -0
- sourcecode/data_labels.py +200 -0
- sourcecode/dynamic_argument_surface.py +12 -5
- sourcecode/format_contract.py +1 -0
- sourcecode/non_coverage.py +45 -0
- sourcecode/openrewrite_recipe.py +278 -0
- sourcecode/posture.py +32 -0
- sourcecode/repository_ir.py +24 -3
- sourcecode/risk.py +453 -92
- sourcecode/sarif.py +618 -0
- sourcecode/validation_inference.py +33 -4
- sourcecode/verify_repo.py +17 -2
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/METADATA +27 -8
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/RECORD +24 -18
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-4.2.0.dist-info → sourcecode-4.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""data_labels.py — what the repository's owner says is sensitive, declared once.
|
|
2
|
+
|
|
3
|
+
Nothing in Java says that `Person.dni` is a national identifier and `Person.id` is
|
|
4
|
+
a surrogate key. A tool that guessed from the name would be guessing (and would be
|
|
5
|
+
guessing in one language, about one naming convention, which is exactly the
|
|
6
|
+
vendor-agnostic rule this product holds itself to: names are evidence, never the
|
|
7
|
+
branch). So the label is **declared**, and everything downstream is measured.
|
|
8
|
+
|
|
9
|
+
The declaration lives beside the other repository-specific semantics the analyzer
|
|
10
|
+
accepts — `sourcecode.config.json`, next to `customSecurityAnnotations`::
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
"dataLabels": [
|
|
14
|
+
{
|
|
15
|
+
"label": "pii",
|
|
16
|
+
"types": ["com.example.domain.Person"],
|
|
17
|
+
"fields": ["com.example.domain.Account#iban"],
|
|
18
|
+
"note": "GDPR art. 4(1) — identifies a natural person"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
`types` labels a type outright; `fields` labels one member, and the type that
|
|
24
|
+
declares it becomes a seed too, because a route that returns the type can return
|
|
25
|
+
the member. Both forms accept a fully-qualified name or the simple name as written
|
|
26
|
+
in the source.
|
|
27
|
+
|
|
28
|
+
Best-effort loading, with one difference from `security_config`: a declaration this
|
|
29
|
+
module could not use is **returned as a problem**, never dropped in silence. A label
|
|
30
|
+
that was ignored because of a typo would otherwise read as "nothing sensitive is
|
|
31
|
+
exposed", which is the worst possible false answer for the question being asked.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Optional
|
|
40
|
+
|
|
41
|
+
#: The same file the custom security annotations are declared in.
|
|
42
|
+
CONFIG_FILENAME = "sourcecode.config.json"
|
|
43
|
+
|
|
44
|
+
#: The key under which labels are declared.
|
|
45
|
+
CONFIG_KEY = "dataLabels"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class DataLabel:
|
|
50
|
+
"""One declared label and the types/members it applies to."""
|
|
51
|
+
|
|
52
|
+
label: str
|
|
53
|
+
types: tuple[str, ...] = ()
|
|
54
|
+
fields: tuple[str, ...] = ()
|
|
55
|
+
note: str = ""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def seed_types(self) -> tuple[str, ...]:
|
|
59
|
+
"""Every type this label reaches into: declared types, plus the types that
|
|
60
|
+
declare a labelled field. A member cannot leave a process without the type
|
|
61
|
+
that holds it, so the type is the unit the graph is walked on — stated here
|
|
62
|
+
rather than left implicit, because it is also the reason the answer
|
|
63
|
+
over-reports a route that maps the type and drops the member."""
|
|
64
|
+
owners = [f.split("#", 1)[0].strip() for f in self.fields if "#" in f]
|
|
65
|
+
seen: list[str] = []
|
|
66
|
+
for name in [*self.types, *owners]:
|
|
67
|
+
if name and name not in seen:
|
|
68
|
+
seen.append(name)
|
|
69
|
+
return tuple(seen)
|
|
70
|
+
|
|
71
|
+
def to_dict(self) -> dict:
|
|
72
|
+
return {
|
|
73
|
+
"label": self.label,
|
|
74
|
+
"types": list(self.types),
|
|
75
|
+
"fields": list(self.fields),
|
|
76
|
+
"note": self.note,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class LabelDeclaration:
|
|
82
|
+
"""Everything the configuration said, including what could not be used."""
|
|
83
|
+
|
|
84
|
+
labels: tuple[DataLabel, ...] = ()
|
|
85
|
+
problems: tuple[dict, ...] = ()
|
|
86
|
+
config_file: Optional[str] = None
|
|
87
|
+
declared: bool = False # the key was present, whatever it contained
|
|
88
|
+
|
|
89
|
+
def to_dict(self) -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"config_file": self.config_file,
|
|
92
|
+
"declared": self.declared,
|
|
93
|
+
"labels": [label.to_dict() for label in self.labels],
|
|
94
|
+
"problems": [dict(p) for p in self.problems],
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _problem(where: str, reason: str, detail: str = "") -> dict:
|
|
99
|
+
out = {"declaration": where, "reason": reason}
|
|
100
|
+
if detail:
|
|
101
|
+
out["detail"] = detail
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_labels(root: Optional[Path]) -> LabelDeclaration:
|
|
106
|
+
"""Read `<root>/sourcecode.config.json` and return the declared labels.
|
|
107
|
+
|
|
108
|
+
Never raises. An absent file is not a problem — it is a repository that has
|
|
109
|
+
declared nothing, and the caller says so in the words of the question.
|
|
110
|
+
"""
|
|
111
|
+
if root is None:
|
|
112
|
+
return LabelDeclaration()
|
|
113
|
+
cfg_path = Path(root) / CONFIG_FILENAME
|
|
114
|
+
try:
|
|
115
|
+
if not cfg_path.is_file():
|
|
116
|
+
return LabelDeclaration(config_file=None)
|
|
117
|
+
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
118
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
119
|
+
return LabelDeclaration(
|
|
120
|
+
config_file=str(cfg_path),
|
|
121
|
+
problems=(_problem(CONFIG_FILENAME, "unreadable", f"{type(exc).__name__}: {exc}"),),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if not isinstance(data, dict) or CONFIG_KEY not in data:
|
|
125
|
+
return LabelDeclaration(config_file=str(cfg_path))
|
|
126
|
+
|
|
127
|
+
raw = data.get(CONFIG_KEY)
|
|
128
|
+
if not isinstance(raw, list):
|
|
129
|
+
return LabelDeclaration(
|
|
130
|
+
config_file=str(cfg_path),
|
|
131
|
+
declared=True,
|
|
132
|
+
problems=(
|
|
133
|
+
_problem(
|
|
134
|
+
CONFIG_KEY, "not_a_list",
|
|
135
|
+
f"expected a list of label declarations, found {type(raw).__name__}",
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
labels: list[DataLabel] = []
|
|
141
|
+
problems: list[dict] = []
|
|
142
|
+
for index, item in enumerate(raw):
|
|
143
|
+
where = f"{CONFIG_KEY}[{index}]"
|
|
144
|
+
if not isinstance(item, dict):
|
|
145
|
+
problems.append(_problem(where, "not_an_object"))
|
|
146
|
+
continue
|
|
147
|
+
name = str(item.get("label") or "").strip()
|
|
148
|
+
if not name:
|
|
149
|
+
problems.append(_problem(where, "no_label", "every declaration needs a `label`"))
|
|
150
|
+
continue
|
|
151
|
+
types = _string_list(item.get("types"), where, "types", problems)
|
|
152
|
+
fields = _string_list(item.get("fields"), where, "fields", problems)
|
|
153
|
+
bad_fields = [f for f in fields if "#" not in f]
|
|
154
|
+
fields = tuple(f for f in fields if "#" in f)
|
|
155
|
+
for bad in bad_fields:
|
|
156
|
+
problems.append(
|
|
157
|
+
_problem(
|
|
158
|
+
f"{where}.fields", "not_a_member_reference",
|
|
159
|
+
f"{bad!r}: a field is written Type#member — a bare type belongs in `types`",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
if not types and not fields:
|
|
163
|
+
problems.append(
|
|
164
|
+
_problem(where, "nothing_labelled", f"{name!r} names neither a type nor a field")
|
|
165
|
+
)
|
|
166
|
+
continue
|
|
167
|
+
labels.append(
|
|
168
|
+
DataLabel(
|
|
169
|
+
label=name, types=types, fields=fields,
|
|
170
|
+
note=str(item.get("note") or "").strip(),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return LabelDeclaration(
|
|
175
|
+
labels=tuple(labels), problems=tuple(problems),
|
|
176
|
+
config_file=str(cfg_path), declared=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _string_list(
|
|
181
|
+
value: object, where: str, key: str, problems: list[dict]
|
|
182
|
+
) -> tuple[str, ...]:
|
|
183
|
+
if value is None:
|
|
184
|
+
return ()
|
|
185
|
+
if isinstance(value, str):
|
|
186
|
+
value = [value]
|
|
187
|
+
if not isinstance(value, list):
|
|
188
|
+
problems.append(
|
|
189
|
+
_problem(f"{where}.{key}", "not_a_list", f"found {type(value).__name__}")
|
|
190
|
+
)
|
|
191
|
+
return ()
|
|
192
|
+
out: list[str] = []
|
|
193
|
+
for entry in value:
|
|
194
|
+
name = str(entry).strip() if isinstance(entry, (str, int)) else ""
|
|
195
|
+
if not name:
|
|
196
|
+
problems.append(_problem(f"{where}.{key}", "empty_entry"))
|
|
197
|
+
continue
|
|
198
|
+
if name not in out:
|
|
199
|
+
out.append(name)
|
|
200
|
+
return tuple(out)
|
|
@@ -40,15 +40,22 @@ def dynamic_argument_surface(
|
|
|
40
40
|
) -> tuple[DynamicArgument, ...]:
|
|
41
41
|
"""Every call-site argument of `shape` across the repository, ordered by
|
|
42
42
|
(owner, occurrence, arg_index). Default "concat" = arguments assembled by
|
|
43
|
-
string/additive concatenation. Pure `ContextGraph` query — reads no source.
|
|
44
|
-
|
|
43
|
+
string/additive concatenation. Pure `ContextGraph` query — reads no source.
|
|
44
|
+
|
|
45
|
+
The owner population is the body-fact layer's own, never a selection of symbol
|
|
46
|
+
kinds. This surface used to enumerate `kind="method"` plus `kind="constructor"`
|
|
47
|
+
nodes, and a request handler is a `kind="endpoint"` node: **every controller body
|
|
48
|
+
in every repository was outside the surface**, silently and with no blind-spot
|
|
49
|
+
note, which is precisely where a query assembled from request-scoped values
|
|
50
|
+
lives. Bodies are keyed by owner, so owners are read from the bodies.
|
|
51
|
+
"""
|
|
45
52
|
out: list[DynamicArgument] = []
|
|
46
|
-
for
|
|
47
|
-
for inv in graph.invocations_in(
|
|
53
|
+
for owner in graph.body_owners():
|
|
54
|
+
for inv in graph.invocations_in(owner):
|
|
48
55
|
for idx, sh in enumerate(inv.arg_shapes):
|
|
49
56
|
if sh == shape:
|
|
50
57
|
out.append(DynamicArgument(
|
|
51
|
-
owner_fqn=
|
|
58
|
+
owner_fqn=owner, call=inv.callee, arg_index=idx,
|
|
52
59
|
shape=sh, occurrence=inv.occurrence,
|
|
53
60
|
))
|
|
54
61
|
return tuple(out)
|
sourcecode/format_contract.py
CHANGED
|
@@ -44,6 +44,7 @@ FORMAT_REGISTRY: "dict[str, tuple[str, ...]]" = {
|
|
|
44
44
|
"impact-chain": ("json", "yaml"),
|
|
45
45
|
"pr-impact": ("json", "text"),
|
|
46
46
|
"migrate-check": ("json", "text"),
|
|
47
|
+
"migrate-recipe": ("json", "yaml"),
|
|
47
48
|
"spring-audit": ("json", "yaml", "github-comment"),
|
|
48
49
|
"explain": ("text", "json"),
|
|
49
50
|
"prepare-context": ("json", "github-comment"),
|
sourcecode/non_coverage.py
CHANGED
|
@@ -179,6 +179,51 @@ NON_COVERAGE: tuple[NonCoverage, ...] = (
|
|
|
179
179
|
"here listed."
|
|
180
180
|
),
|
|
181
181
|
),
|
|
182
|
+
NonCoverage(
|
|
183
|
+
id="NC-008",
|
|
184
|
+
surface="data_exposure",
|
|
185
|
+
not_covered=(
|
|
186
|
+
"Whether a labelled member actually leaves the process on a given "
|
|
187
|
+
"route — field-level dataflow through mappers, DTOs, serializers and "
|
|
188
|
+
"projections."
|
|
189
|
+
),
|
|
190
|
+
why=(
|
|
191
|
+
"Exposure here is measured at TYPE granularity over the same call "
|
|
192
|
+
"reach every other command walks: a route reaches the labelled type, "
|
|
193
|
+
"or its signature names it. Whether the mapping in between keeps the "
|
|
194
|
+
"labelled member is a dataflow question, and answering it by "
|
|
195
|
+
"approximation would put a confident `no` on a route that does leak. "
|
|
196
|
+
"So `call_reach` is published as a supremum and says so."
|
|
197
|
+
),
|
|
198
|
+
instead=(
|
|
199
|
+
"Read the mapping on the routes this answer ranks first — they are the "
|
|
200
|
+
"ones worth a human — and label the DTO too if it carries the member, "
|
|
201
|
+
"which makes the route `signature` instead of `call_reach`."
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
NonCoverage(
|
|
205
|
+
id="NC-009",
|
|
206
|
+
surface="client_usage",
|
|
207
|
+
not_covered=(
|
|
208
|
+
"Whether a route is called from outside this repository — another "
|
|
209
|
+
"service, a mobile app, a partner, a cron job, or a client whose URL "
|
|
210
|
+
"is assembled at run time."
|
|
211
|
+
),
|
|
212
|
+
why=(
|
|
213
|
+
"The join reads the TypeScript/JavaScript checked in beside the server "
|
|
214
|
+
"and only the call shapes whose verb and path are written at the call "
|
|
215
|
+
"site. That is a floor on who calls a route and can never be a ceiling: "
|
|
216
|
+
"the repository cannot observe consumers it does not contain. So a route "
|
|
217
|
+
"with no call site here is published as a candidate for deletion, never "
|
|
218
|
+
"as dead API."
|
|
219
|
+
),
|
|
220
|
+
instead=(
|
|
221
|
+
"Access logs or an API gateway answer who really calls a route. The "
|
|
222
|
+
"opposite direction is decisive without them: a client call matching no "
|
|
223
|
+
"route is a request this repository's own front end makes and its server "
|
|
224
|
+
"does not serve."
|
|
225
|
+
),
|
|
226
|
+
),
|
|
182
227
|
)
|
|
183
228
|
|
|
184
229
|
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""openrewrite_recipe.py — the report, in the form the executor takes.
|
|
2
|
+
|
|
3
|
+
Field evaluation #4, on a repository with 585 blockers and an estimated 357
|
|
4
|
+
person-days: *"con 585 bloqueantes jakarta y 357 días estimados, el informe muere en
|
|
5
|
+
un cajón; el codemod se convierte en PRs."* The findings already name the recipe that
|
|
6
|
+
fixes each one — 296 of 309 on openmrs-core, 144 of 170 on BroadleafCommerce — and a
|
|
7
|
+
reader still had to copy them out of a JSON payload by hand into a file OpenRewrite
|
|
8
|
+
can run.
|
|
9
|
+
|
|
10
|
+
So this module emits that file. **It does not rewrite anything**, and that is a
|
|
11
|
+
position, not a gap (`NC-005`): OpenRewrite is the executor this product recommends,
|
|
12
|
+
and a second, worse migration engine is the one way to lose to it. What is built here
|
|
13
|
+
is its *input* — a declarative composite recipe naming exactly the recipes this
|
|
14
|
+
repository's findings named, plus the invocation that runs it.
|
|
15
|
+
|
|
16
|
+
Three rules the output holds itself to:
|
|
17
|
+
|
|
18
|
+
* **Only recipes a finding named.** The recipe list is derived from the report, never
|
|
19
|
+
from a catalogue of what OpenRewrite can do. A recipe nobody's finding asked for
|
|
20
|
+
would rewrite code nobody measured.
|
|
21
|
+
* **The remainder is published beside it.** Every blocking finding with no recipe is
|
|
22
|
+
listed as manual work, because "run this and you are done" is the failure mode a
|
|
23
|
+
585-blocker report cannot afford twice.
|
|
24
|
+
* **Artifact coordinates are derived from the recipe's own package**, and a recipe
|
|
25
|
+
whose package maps to no known artifact is reported rather than silently dropped —
|
|
26
|
+
an invocation missing one coordinate fails at the executor with a message nobody
|
|
27
|
+
can act on.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Optional
|
|
35
|
+
|
|
36
|
+
SCHEMA_VERSION = "openrewrite-plan-v1"
|
|
37
|
+
|
|
38
|
+
#: Default name of the emitted recipe, and of the file it is written to.
|
|
39
|
+
DEFAULT_RECIPE_NAME = "ask.MigrationFromMeasuredFindings"
|
|
40
|
+
DEFAULT_FILENAME = "rewrite.yml"
|
|
41
|
+
|
|
42
|
+
#: Recipe package prefix → the artifact that publishes it. Derived from the recipe id
|
|
43
|
+
#: itself, because that is the only thing the finding carries; a prefix that is not in
|
|
44
|
+
#: this table is published under `unknown_artifacts` rather than guessed, since a
|
|
45
|
+
#: missing coordinate makes the whole invocation fail at the executor.
|
|
46
|
+
_ARTIFACT_BY_PREFIX: "tuple[tuple[str, str], ...]" = (
|
|
47
|
+
("org.openrewrite.java.migrate.", "org.openrewrite.recipe:rewrite-migrate-java:LATEST"),
|
|
48
|
+
("org.openrewrite.java.spring.", "org.openrewrite.recipe:rewrite-spring:LATEST"),
|
|
49
|
+
("org.openrewrite.java.testing.", "org.openrewrite.recipe:rewrite-testing-frameworks:LATEST"),
|
|
50
|
+
("org.openrewrite.hibernate.", "org.openrewrite.recipe:rewrite-hibernate:LATEST"),
|
|
51
|
+
("org.openrewrite.maven.", ""), # core, shipped with the plugin
|
|
52
|
+
("org.openrewrite.java.", ""), # core java recipes
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class RecipePlan:
|
|
58
|
+
"""A composite recipe built from measured findings, and how to run it."""
|
|
59
|
+
|
|
60
|
+
recipe_name: str
|
|
61
|
+
recipes: tuple[str, ...]
|
|
62
|
+
artifacts: tuple[str, ...]
|
|
63
|
+
unknown_artifacts: tuple[str, ...]
|
|
64
|
+
covered_findings: int
|
|
65
|
+
total_findings: int
|
|
66
|
+
report_blocking_count: int
|
|
67
|
+
manual_findings: tuple[dict, ...]
|
|
68
|
+
population: str
|
|
69
|
+
build_tool: str
|
|
70
|
+
document: str
|
|
71
|
+
invocation: str
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def statement(self) -> str:
|
|
75
|
+
if not self.recipes:
|
|
76
|
+
return (
|
|
77
|
+
f"None of the {self.total_findings} findings in this population carries "
|
|
78
|
+
"an OpenRewrite recipe, so there is no recipe to write and nothing to "
|
|
79
|
+
"run. All of it is manual work, listed under `manual_remainder` — which "
|
|
80
|
+
"is an answer, not an empty result."
|
|
81
|
+
)
|
|
82
|
+
return (
|
|
83
|
+
f"{self.covered_findings} of {self.total_findings} findings name a recipe; "
|
|
84
|
+
f"this document runs those and nothing else. The {len(self.manual_findings)} "
|
|
85
|
+
"left are listed under `manual_remainder` and no recipe applies to them. "
|
|
86
|
+
"Nothing here edits source: the executor does, and only when you run the "
|
|
87
|
+
"invocation."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"schema_version": SCHEMA_VERSION,
|
|
93
|
+
"recipe_name": self.recipe_name,
|
|
94
|
+
"recipes": list(self.recipes),
|
|
95
|
+
"recipe_artifacts": list(self.artifacts),
|
|
96
|
+
"unknown_recipe_artifacts": list(self.unknown_artifacts),
|
|
97
|
+
"coverage": {
|
|
98
|
+
"findings_with_a_recipe": self.covered_findings,
|
|
99
|
+
"findings_considered": self.total_findings,
|
|
100
|
+
"findings_left_manual": len(self.manual_findings),
|
|
101
|
+
"population": self.population,
|
|
102
|
+
# Published beside it because it is the figure `migrate-check`
|
|
103
|
+
# headlines, and it counts something narrower: two populations under
|
|
104
|
+
# one name is the defect this project keeps finding in its own output.
|
|
105
|
+
"report_blocking_count": self.report_blocking_count,
|
|
106
|
+
"report_blocking_count_unit": (
|
|
107
|
+
"critical + high findings in product code — `migrate-check`'s "
|
|
108
|
+
"headline figure, a subset of the population above"
|
|
109
|
+
),
|
|
110
|
+
"basis": (
|
|
111
|
+
"counted over the population named above, from the same "
|
|
112
|
+
"`openrewrite_recipe` field `migrate-check` publishes per finding"
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
"manual_remainder": [dict(row) for row in self.manual_findings],
|
|
116
|
+
"build_tool": self.build_tool,
|
|
117
|
+
"document": self.document,
|
|
118
|
+
"filename": DEFAULT_FILENAME,
|
|
119
|
+
"invocation": self.invocation,
|
|
120
|
+
"statement": self.statement,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def detect_build_tool(root: Path) -> str:
|
|
125
|
+
"""`maven` | `gradle` | `unknown`, from what the repository actually declares."""
|
|
126
|
+
root = Path(root)
|
|
127
|
+
if (root / "pom.xml").is_file():
|
|
128
|
+
return "maven"
|
|
129
|
+
for name in ("build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"):
|
|
130
|
+
if (root / name).is_file():
|
|
131
|
+
return "gradle"
|
|
132
|
+
return "unknown"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def artifact_for(recipe: str) -> Optional[str]:
|
|
136
|
+
"""The artifact coordinate publishing `recipe`, "" for core, None when unknown."""
|
|
137
|
+
for prefix, artifact in _ARTIFACT_BY_PREFIX:
|
|
138
|
+
if recipe.startswith(prefix):
|
|
139
|
+
return artifact
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _yaml_document(recipe_name: str, recipes: "tuple[str, ...]", *, covered: int,
|
|
144
|
+
total: int, manual: int) -> str:
|
|
145
|
+
lines = [
|
|
146
|
+
"# Generated by `ask migrate-recipe` from measured findings — do not hand-edit;",
|
|
147
|
+
"# re-run the command after the code changes and the list changes with it.",
|
|
148
|
+
f"# {covered} of {total} findings name a recipe. The other {manual} are",
|
|
149
|
+
"# manual work and are listed in the command's `manual_remainder`.",
|
|
150
|
+
"---",
|
|
151
|
+
"type: specs.openrewrite.org/v1beta/recipe",
|
|
152
|
+
f"name: {recipe_name}",
|
|
153
|
+
"displayName: Migration recipes named by measured findings",
|
|
154
|
+
"description: >",
|
|
155
|
+
" Every recipe in this list was named by a finding in this repository. It is not",
|
|
156
|
+
" a catalogue of what OpenRewrite can do: a recipe nobody's finding asked for",
|
|
157
|
+
" would rewrite code nobody measured.",
|
|
158
|
+
"recipeList:",
|
|
159
|
+
]
|
|
160
|
+
lines += [f" - {recipe}" for recipe in recipes]
|
|
161
|
+
return "\n".join(lines) + "\n"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _invocation(build_tool: str, recipe_name: str, artifacts: "tuple[str, ...]",
|
|
165
|
+
*, recipe_count: int = 1) -> str:
|
|
166
|
+
if recipe_count == 0:
|
|
167
|
+
# Measured on BroadleafCommerce: 25 blocking findings in product code and not
|
|
168
|
+
# one of them carries a recipe. Printing a runnable command for an empty
|
|
169
|
+
# recipe list would hand the reader a green executor run that changes
|
|
170
|
+
# nothing and reads as "the migration is automated".
|
|
171
|
+
return (
|
|
172
|
+
"# Nothing to run: no finding in the population carries an OpenRewrite "
|
|
173
|
+
"recipe, so there is no recipe to execute. The work is in "
|
|
174
|
+
"`manual_remainder`."
|
|
175
|
+
)
|
|
176
|
+
coordinates = ",".join(a for a in artifacts if a)
|
|
177
|
+
if build_tool == "maven":
|
|
178
|
+
cmd = (
|
|
179
|
+
"mvn -U org.openrewrite.maven:rewrite-maven-plugin:run "
|
|
180
|
+
f"-Drewrite.activeRecipes={recipe_name}"
|
|
181
|
+
)
|
|
182
|
+
if coordinates:
|
|
183
|
+
cmd += f" -Drewrite.recipeArtifactCoordinates={coordinates}"
|
|
184
|
+
return cmd
|
|
185
|
+
if build_tool == "gradle":
|
|
186
|
+
return (
|
|
187
|
+
"# add to build.gradle: plugins { id(\"org.openrewrite.rewrite\") }\n"
|
|
188
|
+
f"# rewrite {{ activeRecipe(\"{recipe_name}\") }}\n"
|
|
189
|
+
+ ("# dependencies { rewrite(\"" + "\"); rewrite(\"".join(
|
|
190
|
+
a for a in artifacts if a) + "\") }\n" if coordinates else "")
|
|
191
|
+
+ "gradle rewriteRun"
|
|
192
|
+
)
|
|
193
|
+
return (
|
|
194
|
+
"# No Maven or Gradle build was found at this path, so the invocation cannot "
|
|
195
|
+
"be written for you. Run the recipe with the OpenRewrite CLI or add the "
|
|
196
|
+
"plugin to whatever builds this repository."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def build_plan(
|
|
201
|
+
report: Any,
|
|
202
|
+
root: Path,
|
|
203
|
+
*,
|
|
204
|
+
recipe_name: str = DEFAULT_RECIPE_NAME,
|
|
205
|
+
include_hygiene: bool = False,
|
|
206
|
+
) -> RecipePlan:
|
|
207
|
+
"""Turn a `MigrationReport` into the recipe document its findings named.
|
|
208
|
+
|
|
209
|
+
The population is the **blocking** findings by default, not every main-context
|
|
210
|
+
one. Measured on openmrs-core: the wider set pulls in `JavaTimeAPIs`, a hygiene
|
|
211
|
+
recipe that rewrites `java.util.Date` across the repository — real work, and not
|
|
212
|
+
work anybody asked for while migrating to Boot 3. A migration recipe that also
|
|
213
|
+
modernises date handling is a diff nobody can review as one thing.
|
|
214
|
+
`--include-hygiene` opts into it, exactly as `migrate-check` does.
|
|
215
|
+
"""
|
|
216
|
+
from sourcecode.migrate_check import _BEST_PRACTICE_TARGETS
|
|
217
|
+
|
|
218
|
+
findings = [
|
|
219
|
+
f for f in getattr(report, "findings", []) or []
|
|
220
|
+
if getattr(f, "code_context", "main") == "main"
|
|
221
|
+
and (include_hygiene
|
|
222
|
+
or getattr(f, "migration_target", "") not in _BEST_PRACTICE_TARGETS)
|
|
223
|
+
]
|
|
224
|
+
recipes = sorted({
|
|
225
|
+
str(f.openrewrite_recipe) for f in findings
|
|
226
|
+
if getattr(f, "openrewrite_recipe", None)
|
|
227
|
+
})
|
|
228
|
+
manual = [
|
|
229
|
+
{
|
|
230
|
+
"rule_id": getattr(f, "rule_id", ""),
|
|
231
|
+
"severity": getattr(f, "severity", ""),
|
|
232
|
+
"title": getattr(f, "title", ""),
|
|
233
|
+
"source_file": getattr(f, "source_file", ""),
|
|
234
|
+
"first_line": getattr(f, "first_line", 0),
|
|
235
|
+
"fix_hint": getattr(f, "fix_hint", ""),
|
|
236
|
+
}
|
|
237
|
+
for f in findings
|
|
238
|
+
if not getattr(f, "openrewrite_recipe", None)
|
|
239
|
+
]
|
|
240
|
+
artifacts: list[str] = []
|
|
241
|
+
unknown: list[str] = []
|
|
242
|
+
for recipe in recipes:
|
|
243
|
+
artifact = artifact_for(recipe)
|
|
244
|
+
if artifact is None:
|
|
245
|
+
unknown.append(recipe)
|
|
246
|
+
elif artifact and artifact not in artifacts:
|
|
247
|
+
artifacts.append(artifact)
|
|
248
|
+
|
|
249
|
+
covered = len(findings) - len(manual)
|
|
250
|
+
build_tool = detect_build_tool(root)
|
|
251
|
+
population = (
|
|
252
|
+
"findings in product code at or above the severity floor of this run, "
|
|
253
|
+
"advisory hygiene included (--include-hygiene)"
|
|
254
|
+
if include_hygiene else
|
|
255
|
+
"findings in product code at or above the severity floor of this run, with "
|
|
256
|
+
"the advisory hygiene bucket excluded — a migration diff that also modernises "
|
|
257
|
+
"date handling cannot be reviewed as one change"
|
|
258
|
+
)
|
|
259
|
+
return RecipePlan(
|
|
260
|
+
recipe_name=recipe_name,
|
|
261
|
+
recipes=tuple(recipes),
|
|
262
|
+
artifacts=tuple(sorted(artifacts)),
|
|
263
|
+
unknown_artifacts=tuple(unknown),
|
|
264
|
+
covered_findings=covered,
|
|
265
|
+
total_findings=len(findings),
|
|
266
|
+
manual_findings=tuple(manual),
|
|
267
|
+
report_blocking_count=int(getattr(report, "blocking_count", 0) or 0),
|
|
268
|
+
population=population,
|
|
269
|
+
build_tool=build_tool,
|
|
270
|
+
document=_yaml_document(
|
|
271
|
+
recipe_name, tuple(recipes),
|
|
272
|
+
covered=covered, total=len(findings), manual=len(manual),
|
|
273
|
+
),
|
|
274
|
+
invocation=_invocation(
|
|
275
|
+
build_tool, recipe_name, tuple(sorted(artifacts)),
|
|
276
|
+
recipe_count=len(recipes),
|
|
277
|
+
),
|
|
278
|
+
)
|
sourcecode/posture.py
CHANGED
|
@@ -883,6 +883,38 @@ _OPEN_BY_RULE = frozenset({"permit_all", "anonymous"})
|
|
|
883
883
|
_UNDECIDED_ACCESS = frozenset({"no_rule_matched", "undecided"})
|
|
884
884
|
|
|
885
885
|
|
|
886
|
+
def endpoint_access_under(
|
|
887
|
+
root: Path,
|
|
888
|
+
profiles: "Optional[set[str]]" = None,
|
|
889
|
+
*,
|
|
890
|
+
cir: "Optional[Any]" = None,
|
|
891
|
+
properties: "Optional[dict[str, str]]" = None,
|
|
892
|
+
) -> "dict[str, str]":
|
|
893
|
+
"""`endpoint_id` → the access decision this profile set produces.
|
|
894
|
+
|
|
895
|
+
The per-endpoint half of `build_posture`, published for the consumers that
|
|
896
|
+
need the decision rather than the report — `ask risk` and `ask enrich` weigh
|
|
897
|
+
it as their access axis. One producer for the resolution, so a composed row
|
|
898
|
+
and a `posture` run can never disagree about the same profile set.
|
|
899
|
+
"""
|
|
900
|
+
return _posture(root, profiles, properties, cir=cir)[1]
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def access_verdict(decision: str) -> str:
|
|
904
|
+
"""A profile-resolved decision, in the vocabulary the risk composition weighs.
|
|
905
|
+
|
|
906
|
+
`no_rule_matched` is **not** open: no rule this analyzer could read matched the
|
|
907
|
+
request, which is unknown coverage. Folding the two together is the confident
|
|
908
|
+
security falsehood `posture` exists to prevent, and this function is where that
|
|
909
|
+
distinction is kept for every consumer at once.
|
|
910
|
+
"""
|
|
911
|
+
if decision in _OPEN_BY_RULE:
|
|
912
|
+
return "open"
|
|
913
|
+
if decision in _UNDECIDED_ACCESS:
|
|
914
|
+
return "unknown"
|
|
915
|
+
return "protected"
|
|
916
|
+
|
|
917
|
+
|
|
886
918
|
def resolve_environments(
|
|
887
919
|
root: Path,
|
|
888
920
|
properties: "Optional[dict[str, str]]" = None,
|
sourcecode/repository_ir.py
CHANGED
|
@@ -1243,6 +1243,17 @@ def _extract_symbols(
|
|
|
1243
1243
|
# from the graph instead of re-reading controller source.
|
|
1244
1244
|
if "@Valid" in _params_str or "@Validated" in _params_str:
|
|
1245
1245
|
pending_ann_values["_validated_params"] = _params_str.strip()
|
|
1246
|
+
# The handler's own parameter list, captured for request
|
|
1247
|
+
# handlers only. `validation_inference` approximated the
|
|
1248
|
+
# body surface by the verb — POST/PUT/PATCH — and declared
|
|
1249
|
+
# that "no IR atom exposes" the parameters, so a handler
|
|
1250
|
+
# taking no arguments at all counted as a body endpoint
|
|
1251
|
+
# accepting an unvalidated body. An empty string is the
|
|
1252
|
+
# answer "no parameters"; the key is absent when the list
|
|
1253
|
+
# spans lines and was not resolved, which is not the same
|
|
1254
|
+
# thing and must not be read as one.
|
|
1255
|
+
if any(a in _ENDPOINT_ANNOTATIONS for a in pending_anns):
|
|
1256
|
+
pending_ann_values["_handler_params"] = _params_str.strip()
|
|
1246
1257
|
# Phase 3.2: annotation-member default. In Java grammar a
|
|
1247
1258
|
# post-paren `default X;` exists ONLY on @interface members
|
|
1248
1259
|
# (`String message() default "...";`) — interface default
|
|
@@ -2267,6 +2278,16 @@ def _type_tokens(expr: str) -> tuple[Optional[str], list[str]]:
|
|
|
2267
2278
|
return outer, args
|
|
2268
2279
|
|
|
2269
2280
|
|
|
2281
|
+
#: Symbol kinds whose DECLARATION carries types — the class-scope counterpart of
|
|
2282
|
+
#: `_BODY_OWNER_KINDS`. A request handler is classified `endpoint` and a factory
|
|
2283
|
+
#: method `bean` at emission, never `method`, so a surface that enumerated methods
|
|
2284
|
+
#: alone could not see the signature of a single controller handler in the
|
|
2285
|
+
#: repository: `structural_references_to_type("Customer")` returned services and
|
|
2286
|
+
#: entities and no route at all, which reads as "no endpoint accepts or returns
|
|
2287
|
+
#: this type". Same shape as the F.4-1 body-surface seam, one scope up.
|
|
2288
|
+
_SIGNATURE_MEMBER_KINDS: "frozenset[str]" = frozenset({"method", "endpoint", "bean"})
|
|
2289
|
+
|
|
2290
|
+
|
|
2270
2291
|
def _extract_class_type_refs(symbols: list[SymbolRecord]) -> dict[str, list[dict]]:
|
|
2271
2292
|
"""Class-structure TypeReference atoms per class FQN (Semantic IR P-A, class
|
|
2272
2293
|
scope). Pure projection over already-extracted symbols — reads NO source, adds
|
|
@@ -2276,15 +2297,15 @@ def _extract_class_type_refs(symbols: list[SymbolRecord]) -> dict[str, list[dict
|
|
|
2276
2297
|
by_class: dict[str, list[tuple[str, str, str, str]]] = {} # cls -> (member, kind, context, type)
|
|
2277
2298
|
for s in symbols:
|
|
2278
2299
|
kind = s.symbol_kind
|
|
2279
|
-
if kind
|
|
2300
|
+
if kind in _SIGNATURE_MEMBER_KINDS:
|
|
2280
2301
|
cls = _enclosing_class(s.symbol)
|
|
2281
2302
|
ro, _ = _type_tokens(s.return_type)
|
|
2282
2303
|
if ro and ro != "void":
|
|
2283
|
-
by_class.setdefault(cls, []).append((s.symbol,
|
|
2304
|
+
by_class.setdefault(cls, []).append((s.symbol, kind, "return_type", ro))
|
|
2284
2305
|
for p in s.param_types:
|
|
2285
2306
|
po, _ = _type_tokens(p)
|
|
2286
2307
|
if po:
|
|
2287
|
-
by_class.setdefault(cls, []).append((s.symbol,
|
|
2308
|
+
by_class.setdefault(cls, []).append((s.symbol, kind, "param_type", po))
|
|
2288
2309
|
elif kind == "constructor":
|
|
2289
2310
|
cls = _enclosing_class(s.symbol)
|
|
2290
2311
|
for p in s.param_types:
|