sourcecode 5.5.2__py3-none-any.whl → 5.5.5__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/_docs/DEFECT-LEDGER.md +16 -1
- sourcecode/_docs/USER_GUIDE.md +8 -2
- sourcecode/cli.py +42 -8
- sourcecode/deployment_prefix.py +18 -12
- sourcecode/repository_ir.py +139 -43
- sourcecode/security_config_scan.py +7 -1
- sourcecode/servlet_surface.py +80 -11
- sourcecode/validation_surface.py +9 -0
- {sourcecode-5.5.2.dist-info → sourcecode-5.5.5.dist-info}/METADATA +3 -3
- {sourcecode-5.5.2.dist-info → sourcecode-5.5.5.dist-info}/RECORD +14 -14
- {sourcecode-5.5.2.dist-info → sourcecode-5.5.5.dist-info}/WHEEL +0 -0
- {sourcecode-5.5.2.dist-info → sourcecode-5.5.5.dist-info}/entry_points.txt +0 -0
- {sourcecode-5.5.2.dist-info → sourcecode-5.5.5.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
|
@@ -16,6 +16,21 @@ The facts these rows are keyed to are published: `ask schema facts-v1` prints th
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## Audit synchronization
|
|
20
|
+
|
|
21
|
+
| ID | Defect | Found | Severity | Status |
|
|
22
|
+
|---|---|---|---|---|
|
|
23
|
+
| AS-01 | `risk`/`enrich` could omit `sql_taint.bounded_interpolations`, making `sinks_total` impossible to audit against its bounded evidence. | 5.4.2 re-audit | Medium | **closed 5.4.2** — both projections preserve the bounded rows; the paired fixture asserts the sink total and the retained bounded interpolation. |
|
|
24
|
+
| AS-02 | Route projection emitted declarations from abstract Spring controllers, creating phantom routes and inflating the unprotected census. | 5.4.2 re-audit | Low | **closed 5.4.2** — abstract declarers are excluded while concrete subtype projection remains admitted; the route-surface regression covers both sides. |
|
|
25
|
+
| AS-03 | The distributed ledger could leave `C3-123` open after the 5.4.1 build already excluded static assets. | 5.4.2 re-audit | Informational | **closed 5.4.2** — the stale state was synchronized to the wheel it documents. |
|
|
26
|
+
| AS-04 | An unconstrained `@RequestMapping` was described by consumers as GET although Spring admits every verb. | 5.5.1 audit | Medium | **closed 5.5.1** — primary `method` is `ANY`; 5.5.4 adds the concrete `methods` list so consumers do not need to infer its expansion. |
|
|
27
|
+
| AS-05 | Test-source endpoint exclusion was neither visible nor reversible for modules whose test code lives below `src/main`. | 5.5.1 audit | High | **closed 5.5.3** — `--include-tests` changes admission and the production answer counts exclusions; 5.5.4 recovery adds the matched path basis. |
|
|
28
|
+
| AS-06 | `@InitBinder` alone was treated as evidence of programmatic validation, contaminating gaps and confidence. | 5.5.1 audit | High | **closed 5.5.1** — only a Spring `Validator` implementation or `setValidator` wiring declares the limit; 5.5.4 recovery publishes that population separately from annotation validators. |
|
|
29
|
+
| AS-07 | Nested types used as field types were reported statically unreferenced by modernization. | 5.5.1 audit | Medium | **closed 5.5.1** — field-type references contribute an incoming static edge. |
|
|
30
|
+
| AS-08 | `spring-audit` severity census could sum to zero beside nonzero findings. | 5.5.4 re-audit | High | **closed 5.5.4** — severity partitions reconcile to `total_findings`; this is asserted in the audit battery. |
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
19
34
|
## Class C1 — One fact, two authorities
|
|
20
35
|
|
|
21
36
|
| ID | Defect | Found | Severity | Status |
|
|
@@ -35,7 +50,7 @@ The facts these rows are keyed to are published: `ask schema facts-v1` prints th
|
|
|
35
50
|
| C1-13 | **Hub-guard publishes `endpoints_total: 0` instead of `unknown`.** A service with 780 callers is capped at `depth=1` for cost — defensible — but reports 0 endpoints reached, contradicting the epistemic standard the tool imposes on itself everywhere else. Hubs are precisely where blast radius is asked for | 3.2.1 (eval #4) | Medium | **closed 3.6.0** — no battery repository crosses the guard (openmrs-core's largest hub, `Context`, has 215 caller classes against a threshold of 500), so it was reproduced on a generated fixture: `bfs_truncated: true`, `depth_reached: 1`, `endpoints_affected_count: 0` beside four more zeros nothing looked for. A reach-derived count of 0 under the cap is now `null`, named in `stats.unknown_under_cap`. The distinction is the point and is asserted both ways: a **non-zero** count under the cap stays a number (it is a floor the effect block already describes), and an **uncapped** run keeps its measured zero, because converting that would be the mirror-image defect. The hub threshold moved to module level so the fixture crosses the real guard instead of a copy of its value |
|
|
36
51
|
| C1-14 | **File-count drift across commands**: 3 334 / 3 337 / 3 342 / 3 374 files for one repository in one session. No command is wrong in isolation; together they cost the one thing a deterministic tool cannot lose — that its numbers agree with themselves | 3.2.1 (eval #4) | Medium | **within-document half closed 3.6.0 · cross-command half does not reproduce.** Of six commands measured on openmrs-core (`--compact`, `endpoints`, `spring-audit`, `migrate-check`, `modernize`, `validation`) only `migrate-check` publishes a scanned-file count at all, so there are no two figures left to disagree. What does reproduce is the same defect *inside one document*: `affected_files` named two populations — 265 under `summary` (every finding, any scope) against 264 under `effort_breakdown` (blocking findings only). Both correct; the shared name was the contradiction. Same remedy as C1-5: the narrower becomes `blocking_affected_files`, both carry a `_basis`, the old key stays an alias, and the battery asserts the two diverge exactly when a finding is non-blocking |
|
|
37
52
|
| C1-15 | **Analysis assumes a single destination.** Asked about a JDK move, `migrate-check` answers `headline_blocker: boot3_migration` and emits fix hints that send a developer to migrate namespaces they never planned to touch (481 `javax.persistence` under MIG-001). There is no way to say "JDK 11, keep Boot 2.7" | 3.2.1 (eval #4) | **High (product)** | **closed 3.6.0** — `--target-jdk N` and `--keep-boot` say which migration is being planned, filtered over the `migration_target` vocabulary every rule already carried rather than a parallel one. Measured on openmrs-core with `--target-jdk 11 --keep-boot`, the row's own acceptance: **MIG-001 85 → 0**, blocking 114 → 0, `headline_blocker` `hibernate_rewrite` → `none_applicable`, and the Hibernate 5→6 rewrite drops with it because Hibernate 6 arrives with Boot 3. Narrowing is not silencing: `metadata.route` publishes the target, the flag and every excluded axis, a `limitations` entry states how many findings were left out and that they are unresolved rather than fixed, and the route's own blockers stay (a JDK 11 route still reports MIG-021, asserted). The default is untouched, verified identical on three repositories |
|
|
38
|
-
| C1-16 | **Four fan-in figures for one class, and the note that promises the relationship is itself false.** Field: `modernize.in_degree` 1 026 / `impact-chain.direct_callers` 611 / `explain.incoming_callers` 519 / `impact.stats.direct_caller_count` 484 for one service, plus 3 222 vs 3 458 endpoints affected. **Re-measured on the battery, 3.4.0, `Money` @ BroadleafCommerce: 673 / 1 868 / 151 / 418** — `impact-chain` is absent from the published relationship and is **2,8× larger than the figure the note declares "always the largest of the three"**, while `explain` vs `impact` differ by **177 %**, not the *"small margin"* the same note promises. In the same runs `impact.stats.endpoints_affected_count: 0` against a populated `endpoints_affected` from `impact-chain` — a **false zero** on the blast-radius axis | 3.2.1 (eval #5) | **High** — this is the number a risk decision is taken with: *"¿el blast radius son 484 o 611 clases?"* | **
|
|
53
|
+
| C1-16 | **Four fan-in figures for one class, and the note that promises the relationship is itself false.** Field: `modernize.in_degree` 1 026 / `impact-chain.direct_callers` 611 / `explain.incoming_callers` 519 / `impact.stats.direct_caller_count` 484 for one service, plus 3 222 vs 3 458 endpoints affected. **Re-measured on the battery, 3.4.0, `Money` @ BroadleafCommerce: 673 / 1 868 / 151 / 418** — `impact-chain` is absent from the published relationship and is **2,8× larger than the figure the note declares "always the largest of the three"**, while `explain` vs `impact` differ by **177 %**, not the *"small margin"* the same note promises. In the same runs `impact.stats.endpoints_affected_count: 0` against a populated `endpoints_affected` from `impact-chain` — a **false zero** on the blast-radius axis | 3.2.1 (eval #5), reopened 5.5.4 | **High** — this is the number a risk decision is taken with: *"¿el blast radius son 484 o 611 clases?"* | **reopened 5.5.4.** The DI and subtype fixtures remain closed, but the claimed one-authority implementation was false for service locators: `impact` kept a local BFS that elevated `Locator#accessor` to every caller of `Locator`, while `impact-chain` used `caller_reach` and did not. 5.5.4 recovery removes that elevation and adds a two-accessor regression fixture; authority unification and cross-command parity remain open as Q-03. |
|
|
39
54
|
| C1-17 | **A fact with an authority still contradicts itself across two commands**: `--compact.analysis_gaps` says *"1 test file for 3 336 Java files"* while `review-pr.test_coverage_risk.repository_has_test_sources` says `false`, in the same repository. C1-2 was closed in 3.2.1 with five consumers rebound, and this evaluation ran **on 3.2.1** | 3.2.1 (eval #5) | Medium | **does not reproduce (measured 3.6.0)** — re-measured as the row instructed. On openmrs-core, keycloak and spring-petclinic neither statement is emitted, and both consumers reach one authority: `--compact` through `analyze_test_sources`, `review-pr` through `is_test_path`. What was missing is now asserted: the authority has **two entry points** and nothing held them to each other, which is exactly the shape a sixth consumer would take. Zero mismatches across a case table including the C1-2 discriminator (a `test` package inside a *main* source root is production code), plus the literal contradiction — "N test files" and "no test sources" — asserted unreachable in both directions |
|
|
40
55
|
| C1-18 | **The profile population differs per command, and the narrower one is what an agent reads.** Field: `--agent` reports a two-name profile set while `prepare-context fix-bug` reports four for the same repository in the same session — and the two names missing from the agent payload are the ones the *build* names for its deployable artefacts. Every finding about conditional security is keyed on that set, so the default agent surface answers the profile question with the population that lost the environments | 3.5.0 (eval #6) | **High** — it is the input to the one capability nothing else in the market has | **closed (unreleased)** — **fixture first, as C1-10 required**, and it reproduced worse than reported: on `tests/fixtures/spring_profile_population` the same repository in one session answered **`["qa","dev"]`** (default view — filenames + overlay directories), **`["dev","prod","staging"]`** (`posture` — config documents + `@Profile`) and **`["dev","prod","qa","staging"]`** (`--agent` / `fix-bug`, the union). Three populations under one name; every conditional-security finding is keyed on it. Authority `spring_profiles.profile_population`: four named signals (`file_naming`, `overlay_directory`, `config_documents`, `annotations`, plus `caller_supplied`), the union, and `by_signal` published so a name's provenance is auditable. The stack detector, `posture` and the serializer section all read it — asserted per surface, not claimed. Closing it exposed two false positives in the signals it inherited, both fixed structurally (never by name — VAI): an `application-{x}` file counts only **beside its base config** (alfresco's `alfresco/messages/application-model_de.properties` + 19 siblings invented twenty environments; now 0) and an overlay directory counts only when it **holds a config file** (spring-petclinic's `db/ messages/ static/ templates/` reported four; now `mysql, postgres`, which are its real profiles) |
|
|
41
56
|
| C1-19 | **File-count drift across commands, with witnesses this time.** 3 337 (`compact.project_summary`) / 3 336 (`analysis_gaps.testing`) / 3 342 (`migrate-check.metadata.java_files_scanned`) for one repository in one session | 3.8.0 (eval #7) | Medium | **closed 3.9.0** — measured, not equalised: the three figures count code-extension files in any language, the non-test denominator of a test ratio, and the .java files handed to the migration rules. Each names its population now, and `migrate-check.files_scanned_basis` states the two it is not. Original note: open — **C1-14's cross-command half, reopened.** 3.6.0 recorded that half as *not reproducing* because on openmrs-core only `migrate-check` published a scanned-file count at all; eval #7 shows three commands publishing one on a repository we do not have. Three populations are plausible and each may be right in isolation (all Java files / files admitted to the graph / files the migration rules scanned), which is exactly the C1-5 remedy: name the unit per figure, or bind them to one authority. Do not fix by making the numbers equal — measure which population each counts first |
|
sourcecode/_docs/USER_GUIDE.md
CHANGED
|
@@ -136,7 +136,7 @@ pipx install sourcecode # isolated install, no venv needed
|
|
|
136
136
|
|
|
137
137
|
# Verify
|
|
138
138
|
ask version
|
|
139
|
-
# ask 5.5.
|
|
139
|
+
# ask 5.5.5
|
|
140
140
|
```
|
|
141
141
|
|
|
142
142
|
Requires Python 3.9+.
|
|
@@ -261,7 +261,13 @@ ask endpoints /path/to/repo
|
|
|
261
261
|
ask endpoints . --format yaml
|
|
262
262
|
```
|
|
263
263
|
|
|
264
|
-
Output: list of `{method, path, controller, handler}`.
|
|
264
|
+
Output: list of `{method, methods, path, controller, handler}`. `method` remains
|
|
265
|
+
the backwards-compatible primary verb; `methods` is the complete declared set.
|
|
266
|
+
An unconstrained `@RequestMapping` reports `method: "ANY"` and
|
|
267
|
+
`methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]`, because it accepts all
|
|
268
|
+
of those verbs rather than defaulting to GET. Covers `@GetMapping`, `@PostMapping`,
|
|
269
|
+
`@PutMapping`, `@DeleteMapping`, `@PatchMapping`, `@RequestMapping`, `@GET`,
|
|
270
|
+
`@POST`, `@PUT`, `@DELETE`, `@PATCH` + `@Path`.
|
|
265
271
|
|
|
266
272
|
Each endpoint carries a `security` policy. Standard annotations (`@PreAuthorize`, `@Secured`, `@RolesAllowed`, …) are recognized automatically; a custom authorization annotation reports `policy: "none_detected"` until declared in a `sourcecode.config.json` at the repo root:
|
|
267
273
|
|
sourcecode/cli.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import contextlib
|
|
4
|
+
import errno
|
|
4
5
|
import hashlib
|
|
5
6
|
import json
|
|
6
7
|
import difflib
|
|
@@ -1196,6 +1197,15 @@ def _safe_write_file(path: "Path", content: str) -> None:
|
|
|
1196
1197
|
fh.flush()
|
|
1197
1198
|
os.fsync(fh.fileno())
|
|
1198
1199
|
tmp.replace(path)
|
|
1200
|
+
# Atomic replacement alone does not prove that the destination is the
|
|
1201
|
+
# requested artifact. Verify the postcondition before reporting a run
|
|
1202
|
+
# output path, especially for non-empty JSON reports.
|
|
1203
|
+
if not path.is_file() or (content and path.stat().st_size == 0):
|
|
1204
|
+
raise OSError(
|
|
1205
|
+
errno.EIO,
|
|
1206
|
+
"atomic output replacement did not produce the requested file",
|
|
1207
|
+
str(path),
|
|
1208
|
+
)
|
|
1199
1209
|
try:
|
|
1200
1210
|
dir_fd = os.open(str(path.parent), os.O_RDONLY)
|
|
1201
1211
|
try:
|
|
@@ -2071,6 +2081,14 @@ def _resolve_files_option(
|
|
|
2071
2081
|
|
|
2072
2082
|
raw = str(files).replace(_text_input.BOM_CHAR, "").strip()
|
|
2073
2083
|
if raw == "-":
|
|
2084
|
+
if sys.stdin.isatty():
|
|
2085
|
+
_emit_error_json(
|
|
2086
|
+
INVALID_INPUT_CODE,
|
|
2087
|
+
"--files - reads the file list from stdin, but stdin is a terminal.",
|
|
2088
|
+
hint=f"Pipe the list: git diff --name-only main | ask {command} . --files -",
|
|
2089
|
+
expected="Piped stdin, a list file, or a comma-separated file list.",
|
|
2090
|
+
)
|
|
2091
|
+
raise typer.Exit(code=1)
|
|
2074
2092
|
return _text_input.parse_path_lines(_text_input.read_stdin_text()), "stdin"
|
|
2075
2093
|
as_path = Path(raw)
|
|
2076
2094
|
if as_path.is_file():
|
|
@@ -8857,7 +8875,8 @@ def spring_audit_cmd(
|
|
|
8857
8875
|
help=(
|
|
8858
8876
|
"Bounded summary: caps `findings` and the per-endpoint security projection "
|
|
8859
8877
|
"to their top 5, most-severe first. Every count is still measured over the "
|
|
8860
|
-
"whole population.
|
|
8878
|
+
"whole population. Stdout also switches to this form only when the full "
|
|
8879
|
+
"JSON exceeds its 50 KB budget; use --output for the complete report."
|
|
8861
8880
|
),
|
|
8862
8881
|
),
|
|
8863
8882
|
table: bool = typer.Option(
|
|
@@ -9194,17 +9213,19 @@ def spring_audit_cmd(
|
|
|
9194
9213
|
"phase that never ran is not evidence there is none."
|
|
9195
9214
|
)
|
|
9196
9215
|
|
|
9216
|
+
_full_data = combined.to_dict()
|
|
9197
9217
|
_bounded_default = (
|
|
9198
9218
|
not compact and output_path is None and format == "json" and not table
|
|
9219
|
+
and len(_json.dumps(_full_data, ensure_ascii=False).encode("utf-8")) > 50_000
|
|
9199
9220
|
)
|
|
9200
|
-
data = (
|
|
9201
|
-
combined.to_compact_dict()
|
|
9202
|
-
if compact or _bounded_default
|
|
9203
|
-
else combined.to_dict()
|
|
9204
|
-
)
|
|
9221
|
+
data = combined.to_compact_dict() if compact or _bounded_default else _full_data
|
|
9205
9222
|
if _bounded_default:
|
|
9206
9223
|
data.pop("mode", None)
|
|
9207
9224
|
data["default_output"] = "bounded"
|
|
9225
|
+
data.pop("compact_note", None)
|
|
9226
|
+
data["stdout_budget_note"] = (
|
|
9227
|
+
"Capped by the stdout payload budget; use --output <file> for the complete report."
|
|
9228
|
+
)
|
|
9208
9229
|
data.setdefault("metadata", {})["cache_layers"] = _cache_state
|
|
9209
9230
|
if _stopped_early:
|
|
9210
9231
|
# C3-87: the block is composed by `PhasedRun.status`, which is the
|
|
@@ -9239,7 +9260,19 @@ def spring_audit_cmd(
|
|
|
9239
9260
|
elif format == "github-comment":
|
|
9240
9261
|
output = _render_spring_audit_github_comment(combined, min_severity)
|
|
9241
9262
|
else:
|
|
9242
|
-
|
|
9263
|
+
_full_data = combined.to_dict()
|
|
9264
|
+
_bounded_default = (
|
|
9265
|
+
not compact and output_path is None and format == "json" and not table
|
|
9266
|
+
and len(_json.dumps(_full_data, ensure_ascii=False).encode("utf-8")) > 50_000
|
|
9267
|
+
)
|
|
9268
|
+
data = combined.to_compact_dict() if compact or _bounded_default else _full_data
|
|
9269
|
+
if _bounded_default:
|
|
9270
|
+
data.pop("mode", None)
|
|
9271
|
+
data["default_output"] = "bounded"
|
|
9272
|
+
data.pop("compact_note", None)
|
|
9273
|
+
data["stdout_budget_note"] = (
|
|
9274
|
+
"Capped by the stdout payload budget; use --output <file> for the complete report."
|
|
9275
|
+
)
|
|
9243
9276
|
output = _serialize_dict(data, format)
|
|
9244
9277
|
|
|
9245
9278
|
# F-AV: our own readers ask for the count under either spelling — the break
|
|
@@ -12850,7 +12883,8 @@ def config_cmd(
|
|
|
12850
12883
|
_answer.say(f"Declaration: none at {_declaration_path}")
|
|
12851
12884
|
_answer.say(
|
|
12852
12885
|
" Nothing is declared, so `data-exposure` answers "
|
|
12853
|
-
"`answered: false
|
|
12886
|
+
"`answered: false`. Custom security annotations may still be inferred "
|
|
12887
|
+
"from usage with Likely confidence; declare them to make that policy Declared."
|
|
12854
12888
|
)
|
|
12855
12889
|
else:
|
|
12856
12890
|
_answer.say(f"Declaration: {_declaration_path}")
|
sourcecode/deployment_prefix.py
CHANGED
|
@@ -23,7 +23,7 @@ from __future__ import annotations
|
|
|
23
23
|
import re
|
|
24
24
|
from dataclasses import dataclass, field
|
|
25
25
|
from pathlib import Path
|
|
26
|
-
from typing import Any, Optional
|
|
26
|
+
from typing import Any, Iterable, Optional
|
|
27
27
|
|
|
28
28
|
# Published Spring Boot property keys (DATA — never a predicate on a repo name).
|
|
29
29
|
_CONTEXT_PATH_KEYS: tuple[str, ...] = (
|
|
@@ -163,25 +163,31 @@ def _profile_of_document(flat: "dict[str, str]", file_profile: Optional[str]) ->
|
|
|
163
163
|
return file_profile
|
|
164
164
|
|
|
165
165
|
|
|
166
|
-
def _config_files(root: Path) -> "list[Path]":
|
|
166
|
+
def _config_files(root: Path, files: Optional[Iterable[Path]] = None) -> "list[Path]":
|
|
167
167
|
"""Spring config files under conventional resource directories.
|
|
168
168
|
|
|
169
169
|
Bounded and sorted for determinism; test resources are excluded because a
|
|
170
170
|
prefix declared for tests is not the deployed one.
|
|
171
171
|
"""
|
|
172
|
+
candidates = files if files is not None else root.rglob("application*.*")
|
|
172
173
|
found: list[Path] = []
|
|
173
|
-
for
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
174
|
+
for path in candidates:
|
|
175
|
+
path = Path(path)
|
|
176
|
+
name = path.name
|
|
177
|
+
if name not in _CONFIG_BASENAMES and not _PROFILE_FILE_RE.match(name):
|
|
178
|
+
continue
|
|
179
|
+
text = str(path).replace("\\", "/")
|
|
180
|
+
in_resources = "/src/main/resources/" in text
|
|
181
|
+
in_config = "/config/" in text
|
|
182
|
+
if not (in_resources or in_config) or "/src/test/" in text or "/target/" in text:
|
|
183
|
+
continue
|
|
184
|
+
found.append(path)
|
|
181
185
|
return sorted(set(found))[:60]
|
|
182
186
|
|
|
183
187
|
|
|
184
|
-
def detect_deployment_prefix(
|
|
188
|
+
def detect_deployment_prefix(
|
|
189
|
+
root: Path, *, files: Optional[Iterable[Path]] = None
|
|
190
|
+
) -> DeploymentPrefix:
|
|
185
191
|
"""Read the deployment prefix from the repo's Spring configuration.
|
|
186
192
|
|
|
187
193
|
Best effort and never raises: an unreadable or malformed config yields an
|
|
@@ -190,7 +196,7 @@ def detect_deployment_prefix(root: Path) -> DeploymentPrefix:
|
|
|
190
196
|
"""
|
|
191
197
|
result = DeploymentPrefix()
|
|
192
198
|
try:
|
|
193
|
-
candidates = _config_files(Path(root))
|
|
199
|
+
candidates = _config_files(Path(root), files)
|
|
194
200
|
except Exception:
|
|
195
201
|
return result
|
|
196
202
|
|
sourcecode/repository_ir.py
CHANGED
|
@@ -3772,17 +3772,40 @@ def _route_path_expression(args_str: str, constants: "Optional[dict[str, str]]")
|
|
|
3772
3772
|
return args_str.strip()
|
|
3773
3773
|
|
|
3774
3774
|
|
|
3775
|
-
|
|
3776
|
-
|
|
3775
|
+
_ANY_HTTP_METHODS = ("GET", "POST", "PUT", "DELETE", "PATCH")
|
|
3776
|
+
|
|
3777
|
+
|
|
3778
|
+
def _parse_route_http_methods(ann_name: str, args_str: str) -> tuple[str, ...]:
|
|
3779
|
+
"""Derive every declared HTTP method, preserving annotation order."""
|
|
3777
3780
|
explicit = _HTTP_METHOD_MAP.get(ann_name)
|
|
3778
3781
|
if explicit:
|
|
3779
|
-
return explicit
|
|
3780
|
-
|
|
3782
|
+
return (explicit,)
|
|
3783
|
+
# Spring accepts both `method=RequestMethod.GET` and the array spelling
|
|
3784
|
+
# `method={RequestMethod.GET, RequestMethod.POST}`. The old expression
|
|
3785
|
+
# expected the token immediately after `=`, so the braces form fell through
|
|
3786
|
+
# to ANY and overstated the reachable verb surface.
|
|
3787
|
+
m = re.search(
|
|
3788
|
+
r'\bmethod\s*=\s*\{?\s*((?:RequestMethod\.)?\w+(?:\s*,\s*(?:RequestMethod\.)?\w+)*)',
|
|
3789
|
+
args_str or "",
|
|
3790
|
+
)
|
|
3781
3791
|
if m:
|
|
3782
|
-
|
|
3792
|
+
methods = re.findall(r'(?:RequestMethod\.)?(\w+)', m.group(1))
|
|
3793
|
+
if methods:
|
|
3794
|
+
return tuple(dict.fromkeys(method.upper() for method in methods))
|
|
3783
3795
|
if ann_name == "@RequestMapping":
|
|
3784
|
-
return "ANY"
|
|
3785
|
-
return
|
|
3796
|
+
return ("ANY",)
|
|
3797
|
+
return ()
|
|
3798
|
+
|
|
3799
|
+
|
|
3800
|
+
def _parse_route_http_method(ann_name: str, args_str: str) -> str:
|
|
3801
|
+
"""Backward-compatible primary HTTP method for a route."""
|
|
3802
|
+
methods = _parse_route_http_methods(ann_name, args_str)
|
|
3803
|
+
return methods[0] if methods else ""
|
|
3804
|
+
|
|
3805
|
+
|
|
3806
|
+
def _published_http_methods(method: str) -> list[str]:
|
|
3807
|
+
"""Concrete verbs a consumer must consider for a route's method label."""
|
|
3808
|
+
return list(_ANY_HTTP_METHODS if method == "ANY" else (method,))
|
|
3786
3809
|
|
|
3787
3810
|
|
|
3788
3811
|
def _parse_route_extras(args_str: str) -> dict:
|
|
@@ -5014,7 +5037,8 @@ def _build_route_surface(
|
|
|
5014
5037
|
_suffix_expr = _route_path_expression(args, constants)
|
|
5015
5038
|
effective_prefixes = class_info.get(cls_simple, {}).get("prefixes", [""])
|
|
5016
5039
|
|
|
5017
|
-
|
|
5040
|
+
route_methods = _parse_route_http_methods(ann_name, args) or ("GET",)
|
|
5041
|
+
method = route_methods[0]
|
|
5018
5042
|
|
|
5019
5043
|
if cls_simple in class_info:
|
|
5020
5044
|
class_info[cls_simple]["own_endpoints"].append(
|
|
@@ -5070,6 +5094,7 @@ def _build_route_surface(
|
|
|
5070
5094
|
"effective_class": cls_fqn,
|
|
5071
5095
|
"path": full_path,
|
|
5072
5096
|
"method": method,
|
|
5097
|
+
"methods": _published_http_methods(method) if method == "ANY" else list(route_methods),
|
|
5073
5098
|
"return_type": (sym.return_type.strip() if sym.return_type else "void"),
|
|
5074
5099
|
"stable_id": sym.stable_id,
|
|
5075
5100
|
"inheritance_depth": 0,
|
|
@@ -5175,6 +5200,7 @@ def _build_route_surface(
|
|
|
5175
5200
|
"effective_class": data["fqn"],
|
|
5176
5201
|
"path": full_path,
|
|
5177
5202
|
"method": verb,
|
|
5203
|
+
"methods": _published_http_methods(verb),
|
|
5178
5204
|
"return_type": _parent_rt_by_sym.get(declaring_sym, "void"),
|
|
5179
5205
|
"stable_id": stable_id,
|
|
5180
5206
|
"inheritance_depth": depth,
|
|
@@ -6415,6 +6441,37 @@ def extract_java_endpoints(
|
|
|
6415
6441
|
and "target/" not in str(p).replace("\\", "/")
|
|
6416
6442
|
and not any(f in str(p).replace("\\", "/") for f in _CLIENT_PATH_FRAGMENTS)
|
|
6417
6443
|
)
|
|
6444
|
+
# `endpoints` needs XML both for Spring Security and WebScript detection.
|
|
6445
|
+
# One inventory avoids seven name-specific walks plus a later desc.xml walk.
|
|
6446
|
+
_xml_files = sorted(root.rglob("*.xml"))
|
|
6447
|
+
# Deployment-prefix resolution formerly ran two independent `root.glob()`
|
|
6448
|
+
# calls. One candidate list keeps that work bounded and reusable below.
|
|
6449
|
+
_config_files = sorted(root.rglob("application*.*"))
|
|
6450
|
+
_excluded_test_source_routes = 0
|
|
6451
|
+
_excluded_test_patterns: set[str] = set()
|
|
6452
|
+
if not include_tests:
|
|
6453
|
+
_route_marker = _re.compile(
|
|
6454
|
+
r"@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping|GET|POST|PUT|DELETE|PATCH|Path)\b"
|
|
6455
|
+
)
|
|
6456
|
+
for _candidate in root.rglob("*.java"):
|
|
6457
|
+
_candidate_rel = _rel(_candidate)
|
|
6458
|
+
if not is_test_source_file(_candidate_rel):
|
|
6459
|
+
continue
|
|
6460
|
+
_candidate_parts = _candidate_rel.lower().split("/")
|
|
6461
|
+
if "test-suite" in _candidate_parts:
|
|
6462
|
+
_excluded_test_patterns.add("test-suite/")
|
|
6463
|
+
elif "src/test/" in _candidate_rel.lower():
|
|
6464
|
+
_excluded_test_patterns.add("src/test/")
|
|
6465
|
+
elif "src/it/" in _candidate_rel.lower():
|
|
6466
|
+
_excluded_test_patterns.add("src/it/")
|
|
6467
|
+
else:
|
|
6468
|
+
_excluded_test_patterns.add("test-source classification")
|
|
6469
|
+
try:
|
|
6470
|
+
_excluded_test_source_routes += len(
|
|
6471
|
+
_route_marker.findall(_candidate.read_text(encoding="utf-8", errors="replace"))
|
|
6472
|
+
)
|
|
6473
|
+
except OSError:
|
|
6474
|
+
continue
|
|
6418
6475
|
|
|
6419
6476
|
all_symbols: list[SymbolRecord] = []
|
|
6420
6477
|
extends_map: dict[str, str] = {}
|
|
@@ -6620,6 +6677,7 @@ def extract_java_endpoints(
|
|
|
6620
6677
|
|
|
6621
6678
|
entry: dict = {
|
|
6622
6679
|
"method": route["method"],
|
|
6680
|
+
"methods": route.get("methods", _published_http_methods(route["method"])),
|
|
6623
6681
|
"path": route["path"],
|
|
6624
6682
|
"controller": controller,
|
|
6625
6683
|
# Two controllers can share a simple name across packages: keycloak
|
|
@@ -6776,23 +6834,23 @@ def extract_java_endpoints(
|
|
|
6776
6834
|
re.IGNORECASE,
|
|
6777
6835
|
)
|
|
6778
6836
|
_xml_security_detected = False
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6837
|
+
for _xf in _xml_files:
|
|
6838
|
+
_xml_name = _xf.name.lower()
|
|
6839
|
+
if not (
|
|
6840
|
+
"security" in _xml_name
|
|
6841
|
+
or "spring" in _xml_name
|
|
6842
|
+
or _xml_name.endswith("-context.xml")
|
|
6843
|
+
or _xml_name.endswith("context.xml")
|
|
6844
|
+
):
|
|
6845
|
+
continue
|
|
6846
|
+
if "target/" in str(_xf).replace("\\", "/"):
|
|
6847
|
+
continue
|
|
6848
|
+
try:
|
|
6849
|
+
_xt = _xf.read_text(encoding="utf-8", errors="replace")
|
|
6850
|
+
except OSError:
|
|
6851
|
+
continue
|
|
6852
|
+
if _XML_SECURITY_RE.search(_xt):
|
|
6853
|
+
_xml_security_detected = True
|
|
6796
6854
|
break
|
|
6797
6855
|
|
|
6798
6856
|
if _xml_security_detected:
|
|
@@ -6892,7 +6950,7 @@ def extract_java_endpoints(
|
|
|
6892
6950
|
try:
|
|
6893
6951
|
from sourcecode.deployment_prefix import detect_deployment_prefix
|
|
6894
6952
|
|
|
6895
|
-
_prefix = detect_deployment_prefix(root)
|
|
6953
|
+
_prefix = detect_deployment_prefix(root, files=_config_files)
|
|
6896
6954
|
if _prefix.sources:
|
|
6897
6955
|
result["deployment_prefix"] = _prefix.to_dict()
|
|
6898
6956
|
if _prefix.prefix:
|
|
@@ -6912,6 +6970,17 @@ def extract_java_endpoints(
|
|
|
6912
6970
|
)
|
|
6913
6971
|
elif _test_util_endpoints:
|
|
6914
6972
|
result["test_util_included"] = len(_test_util_endpoints)
|
|
6973
|
+
if _excluded_test_source_routes:
|
|
6974
|
+
result["test_source_excluded"] = _excluded_test_source_routes
|
|
6975
|
+
result["test_source_excluded_detail"] = {
|
|
6976
|
+
"count": _excluded_test_source_routes,
|
|
6977
|
+
"patterns_matched": sorted(_excluded_test_patterns),
|
|
6978
|
+
"basis": "path classification; use --include-tests to include these routes",
|
|
6979
|
+
}
|
|
6980
|
+
result.setdefault("notes", []).append(
|
|
6981
|
+
f"{_excluded_test_source_routes} route annotation(s) from test source trees "
|
|
6982
|
+
"were excluded; use --include-tests to include them."
|
|
6983
|
+
)
|
|
6915
6984
|
# Surface incomplete-endpoint warnings (interface-defined controllers) only when
|
|
6916
6985
|
# present, to keep output backward-compatible for the common case.
|
|
6917
6986
|
if endpoint_warnings:
|
|
@@ -6949,7 +7018,9 @@ def extract_java_endpoints(
|
|
|
6949
7018
|
# (*.desc.xml: <url>/<authentication>/<transaction>) in addition to the Java
|
|
6950
7019
|
# signals gathered above.
|
|
6951
7020
|
_webscript_descriptors = 0
|
|
6952
|
-
for _df in
|
|
7021
|
+
for _df in _xml_files:
|
|
7022
|
+
if not _df.name.endswith(".desc.xml"):
|
|
7023
|
+
continue
|
|
6953
7024
|
if "target/" in str(_df).replace("\\", "/"):
|
|
6954
7025
|
continue
|
|
6955
7026
|
try:
|
|
@@ -6977,7 +7048,7 @@ def extract_java_endpoints(
|
|
|
6977
7048
|
from sourcecode.servlet_surface import build_servlet_surface
|
|
6978
7049
|
|
|
6979
7050
|
_nonspring["servlets"] = int(
|
|
6980
|
-
build_servlet_surface(root).get("declarations", 0)
|
|
7051
|
+
build_servlet_surface(root, files=[*java_files, *_xml_files]).get("declarations", 0)
|
|
6981
7052
|
)
|
|
6982
7053
|
except Exception:
|
|
6983
7054
|
pass
|
|
@@ -7131,6 +7202,10 @@ _DISPLAY_INDIRECT_CALLERS = 50
|
|
|
7131
7202
|
HUB_CALLER_THRESHOLD = 500
|
|
7132
7203
|
HUB_SAMPLE_SIZE = 20
|
|
7133
7204
|
HUB_SAMPLE_DEPTH = 2
|
|
7205
|
+
# A frontier is a hub for this query when it materially exceeds the target's
|
|
7206
|
+
# own fan-in, even if it does not reach the global safety cap above.
|
|
7207
|
+
HUB_RELATIVE_FANIN_MULTIPLIER = 3
|
|
7208
|
+
HUB_RELATIVE_FANIN_FLOOR = 20
|
|
7134
7209
|
|
|
7135
7210
|
|
|
7136
7211
|
def _symbol_listing_advice(root: "Optional[Path]") -> str:
|
|
@@ -7283,21 +7358,15 @@ def compute_blast_radius(
|
|
|
7283
7358
|
_rg_index = _class_method_index(reverse_graph)
|
|
7284
7359
|
|
|
7285
7360
|
def _walk_callers(_node: str) -> list[str]:
|
|
7286
|
-
"""
|
|
7361
|
+
"""Return the one-authority caller set for this exact frontier node.
|
|
7287
7362
|
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7363
|
+
`caller_reach` expands class nodes through their method keys, which is
|
|
7364
|
+
required for DI-normalized class frontiers. It deliberately does not
|
|
7365
|
+
invert that containment for a method: callers of `Locator#getA` are a
|
|
7366
|
+
subset of callers of `Locator`, and adding the latter leaks users of
|
|
7367
|
+
every other locator accessor into the blast cone.
|
|
7292
7368
|
"""
|
|
7293
|
-
|
|
7294
|
-
_node, reverse_graph, _rg_index, _impl_graph
|
|
7295
|
-
))
|
|
7296
|
-
if "#" in _node:
|
|
7297
|
-
candidates.extend(_all_callers_from_rg(
|
|
7298
|
-
_enclosing_class(_node), reverse_graph, _rg_index, _impl_graph
|
|
7299
|
-
))
|
|
7300
|
-
return list(dict.fromkeys(candidates))
|
|
7369
|
+
return _all_callers_from_rg(_node, reverse_graph, _rg_index, _impl_graph)
|
|
7301
7370
|
# Callers inject the interface, not the implementation, so a walk that does not
|
|
7302
7371
|
# know the implements relation stops one hop short of every DI consumer. Without
|
|
7303
7372
|
# it this traversal reached 200 classes and **no controller at all** for
|
|
@@ -7417,6 +7486,26 @@ def compute_blast_radius(
|
|
|
7417
7486
|
if _effective_depth > 1:
|
|
7418
7487
|
queue.append((c, 1))
|
|
7419
7488
|
|
|
7489
|
+
# A locator accessor can be a narrow method frontier on a broad class hub.
|
|
7490
|
+
# Measure the owner only to classify that frontier, never to expand it: the
|
|
7491
|
+
# latter would merge callers of unrelated accessors (Q-01).
|
|
7492
|
+
_seed_fan_in = max(1, len(set(direct_callers)))
|
|
7493
|
+
_hub_frontier_cutoff = max(
|
|
7494
|
+
HUB_RELATIVE_FANIN_FLOOR,
|
|
7495
|
+
_seed_fan_in * HUB_RELATIVE_FANIN_MULTIPLIER,
|
|
7496
|
+
)
|
|
7497
|
+
|
|
7498
|
+
def _is_hub_frontier(_node: str) -> bool:
|
|
7499
|
+
_fan_in = len(_walk_callers(_node))
|
|
7500
|
+
if "#" in _node:
|
|
7501
|
+
_fan_in = max(
|
|
7502
|
+
_fan_in,
|
|
7503
|
+
len(_all_callers_from_rg(
|
|
7504
|
+
_enclosing_class(_node), reverse_graph, _rg_index, _impl_graph
|
|
7505
|
+
)),
|
|
7506
|
+
)
|
|
7507
|
+
return _fan_in > _hub_frontier_cutoff
|
|
7508
|
+
|
|
7420
7509
|
# BFS for indirect callers
|
|
7421
7510
|
indirect_callers: list[str] = []
|
|
7422
7511
|
_indirect_parent: dict[str, str] = {}
|
|
@@ -7428,7 +7517,7 @@ def compute_blast_radius(
|
|
|
7428
7517
|
if depth >= _effective_depth:
|
|
7429
7518
|
continue
|
|
7430
7519
|
_node_callers = _walk_callers(node)
|
|
7431
|
-
if
|
|
7520
|
+
if _is_hub_frontier(node):
|
|
7432
7521
|
_hub_frontier_nodes.add(node)
|
|
7433
7522
|
for caller in _node_callers:
|
|
7434
7523
|
if caller not in visited:
|
|
@@ -7679,7 +7768,7 @@ def compute_blast_radius(
|
|
|
7679
7768
|
if _depth >= _effective_depth:
|
|
7680
7769
|
continue
|
|
7681
7770
|
_next = _walk_callers(_node)
|
|
7682
|
-
if
|
|
7771
|
+
if _is_hub_frontier(_node):
|
|
7683
7772
|
continue
|
|
7684
7773
|
for _caller in _next:
|
|
7685
7774
|
if _caller not in _without_hubs_seen:
|
|
@@ -7853,6 +7942,10 @@ def compute_blast_radius(
|
|
|
7853
7942
|
_indirect_summary = (
|
|
7854
7943
|
_indirect_ordered if _indirect_cap is None else _indirect_ordered[:_indirect_cap]
|
|
7855
7944
|
)
|
|
7945
|
+
_indirect_by_depth: dict[str, int] = {}
|
|
7946
|
+
for _node in indirect_callers:
|
|
7947
|
+
_depth_key = str(all_affected.get(_node, 0))
|
|
7948
|
+
_indirect_by_depth[_depth_key] = _indirect_by_depth.get(_depth_key, 0) + 1
|
|
7856
7949
|
def _via_hub(_node: str) -> bool:
|
|
7857
7950
|
_seen_path: set[str] = set()
|
|
7858
7951
|
_cursor = _node
|
|
@@ -7914,6 +8007,9 @@ def compute_blast_radius(
|
|
|
7914
8007
|
),
|
|
7915
8008
|
"indirect_callers": _indirect_summary,
|
|
7916
8009
|
"indirect_caller_details": _indirect_details,
|
|
8010
|
+
"indirect_callers_unit": "reference symbols (methods or classes)",
|
|
8011
|
+
"indirect_callers_by_depth": _indirect_by_depth,
|
|
8012
|
+
"indirect_callers_without_hubs": _without_hubs,
|
|
7917
8013
|
"container_wired": [w.to_dict() for w in _container_wired],
|
|
7918
8014
|
"endpoints_affected": endpoints_affected,
|
|
7919
8015
|
"mappers_affected": mappers_affected,
|
|
@@ -276,7 +276,13 @@ def _scan_java(source: str, rel: str) -> "list[SecurityConfigObservation]":
|
|
|
276
276
|
))
|
|
277
277
|
break # one observation per algorithm per file
|
|
278
278
|
for encoder in _DEPRECATED_ENCODERS:
|
|
279
|
-
|
|
279
|
+
# An import only makes the type available; it is not evidence that
|
|
280
|
+
# a password encoder is used. Require construction or the legacy
|
|
281
|
+
# factory call so the witness opens on a remediation site.
|
|
282
|
+
m = re.search(
|
|
283
|
+
rf"(?:new\s+{re.escape(encoder)}\b|{re.escape(encoder)}\s*\.\s*getInstance\s*\()",
|
|
284
|
+
source,
|
|
285
|
+
)
|
|
280
286
|
if m:
|
|
281
287
|
out.append(SecurityConfigObservation(
|
|
282
288
|
rule="SEC-004",
|
sourcecode/servlet_surface.py
CHANGED
|
@@ -20,10 +20,11 @@ So the answer is a second, named population beside the first, with its own total
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
import os
|
|
23
24
|
import re
|
|
24
25
|
from dataclasses import dataclass
|
|
25
26
|
from pathlib import Path
|
|
26
|
-
from typing import Optional
|
|
27
|
+
from typing import Iterable, Optional
|
|
27
28
|
from xml.etree import ElementTree as ET
|
|
28
29
|
|
|
29
30
|
POPULATION = "servlet"
|
|
@@ -82,11 +83,12 @@ def _child_text(elem: "ET.Element", name: str) -> str:
|
|
|
82
83
|
return ""
|
|
83
84
|
|
|
84
85
|
|
|
85
|
-
def _from_web_xml(xml_path: Path, relative: str) -> "list[ServletMapping]":
|
|
86
|
+
def _from_web_xml(xml_path: Path, relative: str) -> "tuple[list[ServletMapping], int]":
|
|
86
87
|
try:
|
|
87
|
-
|
|
88
|
+
text = xml_path.read_text(encoding="utf-8", errors="replace")
|
|
89
|
+
root = ET.fromstring(text)
|
|
88
90
|
except Exception:
|
|
89
|
-
return []
|
|
91
|
+
return [], 0
|
|
90
92
|
classes: "dict[str, str]" = {}
|
|
91
93
|
for elem in root.iter():
|
|
92
94
|
if _local(elem.tag) != "servlet":
|
|
@@ -96,10 +98,17 @@ def _from_web_xml(xml_path: Path, relative: str) -> "list[ServletMapping]":
|
|
|
96
98
|
if name:
|
|
97
99
|
classes[name] = klass
|
|
98
100
|
out: "list[ServletMapping]" = []
|
|
101
|
+
undeclared_servlet_mappings = 0
|
|
99
102
|
for elem in root.iter():
|
|
100
103
|
if _local(elem.tag) != "servlet-mapping":
|
|
101
104
|
continue
|
|
102
105
|
name = _child_text(elem, "servlet-name")
|
|
106
|
+
if not name or name not in classes:
|
|
107
|
+
undeclared_servlet_mappings += sum(
|
|
108
|
+
1 for child in elem.iter()
|
|
109
|
+
if _local(child.tag) == "url-pattern" and (child.text or "").strip()
|
|
110
|
+
)
|
|
111
|
+
continue
|
|
103
112
|
for child in elem.iter():
|
|
104
113
|
if _local(child.tag) != "url-pattern" or not (child.text or "").strip():
|
|
105
114
|
continue
|
|
@@ -110,7 +119,31 @@ def _from_web_xml(xml_path: Path, relative: str) -> "list[ServletMapping]":
|
|
|
110
119
|
source_file=relative,
|
|
111
120
|
source="web.xml",
|
|
112
121
|
))
|
|
113
|
-
|
|
122
|
+
# Keep a tolerant lexical fallback for descriptors containing legacy XML
|
|
123
|
+
# constructs that ElementTree rejects or partially normalizes. Comments are
|
|
124
|
+
# removed first so commented deployment examples never become live routes.
|
|
125
|
+
try:
|
|
126
|
+
active = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
|
|
127
|
+
for block in re.findall(r"<servlet-mapping\b[^>]*>(.*?)</servlet-mapping>", active, re.DOTALL | re.IGNORECASE):
|
|
128
|
+
name_match = re.search(r"<servlet-name\b[^>]*>\s*([^<]+?)\s*</servlet-name>", block, re.DOTALL | re.IGNORECASE)
|
|
129
|
+
pattern_match = re.search(r"<url-pattern\b[^>]*>\s*([^<]+?)\s*</url-pattern>", block, re.DOTALL | re.IGNORECASE)
|
|
130
|
+
if not pattern_match:
|
|
131
|
+
continue
|
|
132
|
+
servlet_name = name_match.group(1).strip() if name_match else ""
|
|
133
|
+
if not servlet_name or servlet_name not in classes:
|
|
134
|
+
continue
|
|
135
|
+
candidate = ServletMapping(
|
|
136
|
+
url_pattern=pattern_match.group(1).strip(),
|
|
137
|
+
servlet_name=servlet_name,
|
|
138
|
+
servlet_class=classes[servlet_name],
|
|
139
|
+
source_file=relative,
|
|
140
|
+
source="web.xml",
|
|
141
|
+
)
|
|
142
|
+
if candidate not in out:
|
|
143
|
+
out.append(candidate)
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
return out, undeclared_servlet_mappings
|
|
114
147
|
|
|
115
148
|
|
|
116
149
|
def _from_registration_beans(java_path: Path, relative: str) -> "list[ServletMapping]":
|
|
@@ -144,14 +177,37 @@ def _from_registration_beans(java_path: Path, relative: str) -> "list[ServletMap
|
|
|
144
177
|
return out
|
|
145
178
|
|
|
146
179
|
|
|
147
|
-
def build_servlet_surface(
|
|
180
|
+
def build_servlet_surface(
|
|
181
|
+
root: Path,
|
|
182
|
+
*,
|
|
183
|
+
limit: Optional[int] = None,
|
|
184
|
+
files: Optional[Iterable[Path]] = None,
|
|
185
|
+
) -> dict:
|
|
148
186
|
"""Every servlet-mapped URL pattern under `root`, as a population of its own."""
|
|
149
187
|
from sourcecode import non_coverage
|
|
150
188
|
|
|
151
189
|
root = Path(root)
|
|
152
190
|
mappings: "list[ServletMapping]" = []
|
|
153
|
-
|
|
154
|
-
|
|
191
|
+
undeclared_servlet_mappings = 0
|
|
192
|
+
if files is None:
|
|
193
|
+
candidates: list[Path] = []
|
|
194
|
+
for directory, dirs, names in os.walk(root):
|
|
195
|
+
dirs[:] = sorted(name for name in dirs if name not in _SKIP_DIRS)
|
|
196
|
+
candidates.extend(
|
|
197
|
+
Path(directory) / name
|
|
198
|
+
for name in sorted(names)
|
|
199
|
+
if name == "web.xml" or name.endswith(".java")
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
candidates = sorted(
|
|
203
|
+
{
|
|
204
|
+
Path(path)
|
|
205
|
+
for path in files
|
|
206
|
+
if Path(path).name == "web.xml" or Path(path).suffix == ".java"
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
for path in candidates:
|
|
210
|
+
if not path.is_file():
|
|
155
211
|
continue
|
|
156
212
|
try:
|
|
157
213
|
# POSIX, like every other path this CLI publishes. C2-18 fixed this
|
|
@@ -164,7 +220,9 @@ def build_servlet_surface(root: Path, *, limit: Optional[int] = None) -> dict:
|
|
|
164
220
|
except ValueError:
|
|
165
221
|
relative = Path(path).as_posix()
|
|
166
222
|
if path.name == "web.xml":
|
|
167
|
-
|
|
223
|
+
parsed, omitted = _from_web_xml(path, relative)
|
|
224
|
+
mappings.extend(parsed)
|
|
225
|
+
undeclared_servlet_mappings += omitted
|
|
168
226
|
elif path.suffix == ".java":
|
|
169
227
|
mappings.extend(_from_registration_beans(path, relative))
|
|
170
228
|
|
|
@@ -176,6 +234,7 @@ def build_servlet_surface(root: Path, *, limit: Optional[int] = None) -> dict:
|
|
|
176
234
|
_unique: dict[str, ServletMapping] = {}
|
|
177
235
|
for mapping in sorted(mappings, key=lambda m: (m.url_pattern, m.source_file, m.source)):
|
|
178
236
|
_unique.setdefault(mapping.url_pattern, mapping)
|
|
237
|
+
declared_mappings = list(mappings)
|
|
179
238
|
mappings = list(_unique.values())
|
|
180
239
|
patterns = sorted(_unique)
|
|
181
240
|
by_source: "dict[str, int]" = {}
|
|
@@ -187,15 +246,25 @@ def build_servlet_surface(root: Path, *, limit: Optional[int] = None) -> dict:
|
|
|
187
246
|
)]
|
|
188
247
|
shown = rows if limit is None else rows[:limit]
|
|
189
248
|
|
|
249
|
+
display_omitted = len(rows) - len(shown)
|
|
250
|
+
omitted_total = undeclared_servlet_mappings + display_omitted
|
|
251
|
+
omitted_by_reason: dict[str, int] = {}
|
|
252
|
+
if undeclared_servlet_mappings:
|
|
253
|
+
omitted_by_reason["servlet_not_declared"] = undeclared_servlet_mappings
|
|
254
|
+
if display_omitted:
|
|
255
|
+
omitted_by_reason["display_limit"] = display_omitted
|
|
256
|
+
|
|
190
257
|
return {
|
|
191
258
|
"schema_version": SCHEMA_VERSION,
|
|
192
259
|
"population_key": POPULATION,
|
|
193
260
|
"total_url_patterns": len(patterns),
|
|
194
|
-
"declarations": len(
|
|
261
|
+
"declarations": len(declared_mappings),
|
|
262
|
+
"declarations_unit": "live servlet-mapping declarations before URL-pattern deduplication",
|
|
195
263
|
"by_source": dict(sorted(by_source.items())),
|
|
196
264
|
"url_patterns": patterns,
|
|
197
265
|
"mappings": shown,
|
|
198
|
-
"mappings_omitted":
|
|
266
|
+
"mappings_omitted": omitted_total,
|
|
267
|
+
"mappings_omitted_by_reason": omitted_by_reason,
|
|
199
268
|
"population": (
|
|
200
269
|
"servlet mappings declared in `web.xml` or registered as a "
|
|
201
270
|
"`ServletRegistrationBean` — HTTP surface that is NOT a Spring handler "
|
sourcecode/validation_surface.py
CHANGED
|
@@ -614,7 +614,16 @@ def build_validation_surface(
|
|
|
614
614
|
"summary": {
|
|
615
615
|
"endpoints_with_body": len(out_endpoints),
|
|
616
616
|
"validated_fields": total_validated_fields,
|
|
617
|
+
# This catalog contains annotation-based ConstraintValidator types.
|
|
618
|
+
# Spring Validator wiring is detected separately below and must not
|
|
619
|
+
# be read as zero just because it belongs to another population.
|
|
617
620
|
"custom_validators_declared": len(custom_list),
|
|
621
|
+
"custom_validators_declared_unit": "annotation-based ConstraintValidator declarations",
|
|
622
|
+
"programmatic_validators_declared": len(programmatic_evidence),
|
|
623
|
+
"programmatic_validators_declared_unit": (
|
|
624
|
+
"detected org.springframework.validation.Validator implementations "
|
|
625
|
+
"or setValidator wiring; rules are not modeled per endpoint"
|
|
626
|
+
),
|
|
618
627
|
"custom_validators_linked": len(custom_used),
|
|
619
628
|
"gaps": len(gaps),
|
|
620
629
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sourcecode
|
|
3
|
-
Version: 5.5.
|
|
3
|
+
Version: 5.5.5
|
|
4
4
|
Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Keywords: agents,ai,codebase,context,developer-tools,llm
|
|
@@ -42,7 +42,7 @@ Description-Content-Type: text/markdown
|
|
|
42
42
|
|
|
43
43
|
**Context · Impact · Migration · Architecture · Review — everything from one structural model.**
|
|
44
44
|
|
|
45
|
-

|
|
46
46
|

|
|
47
47
|
|
|
48
48
|
> **ASK Engine** is the product. The CLI command is **`ask`**. The legacy **`sourcecode`**
|
|
@@ -126,7 +126,7 @@ brew tap haroundominique/sourcecode && brew install sourcecode
|
|
|
126
126
|
# pip / pipx
|
|
127
127
|
pipx install sourcecode # or: pip install sourcecode
|
|
128
128
|
|
|
129
|
-
ask version # ask 5.5.
|
|
129
|
+
ask version # ask 5.5.5 — and, on a build that has aged,
|
|
130
130
|
# how many releases have probably shipped since
|
|
131
131
|
```
|
|
132
132
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
sourcecode/__init__.py,sha256=
|
|
1
|
+
sourcecode/__init__.py,sha256=LWoy6uySGMfIcuMSAGcCF4LHEF2d_DbNo4PrVl8kous,308
|
|
2
2
|
sourcecode/adaptive_scanner.py,sha256=yJBKjNpkY6bpueYJ2YnRezen3sYZDecEt7WaaNWdqug,9466
|
|
3
3
|
sourcecode/archetype.py,sha256=CZvRLpkHot_D8D3JFQVorr-EHDJyh0BbS7RnSTqBigM,40499
|
|
4
4
|
sourcecode/architectural_baseline.py,sha256=4GiMVBLJVHvRKSWQGf2ZVurI1-R0qk5pmaevgvGSgVA,26325
|
|
@@ -19,7 +19,7 @@ sourcecode/chain_rules.py,sha256=Bi6UHfgd-GxWswmnHRcPz5jdbAuqka3Zkz_P-MTvqhw,127
|
|
|
19
19
|
sourcecode/change_plan.py,sha256=aX1mp2XnlDN-8R2BcTiu8HlkDwQ_4fN1ZLckj0VMXHM,9945
|
|
20
20
|
sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
|
|
21
21
|
sourcecode/classifier.py,sha256=JBzPwSSrDG-tUHAbcKB678HRbjLpD-ohzbzzO62mgpo,20114
|
|
22
|
-
sourcecode/cli.py,sha256=
|
|
22
|
+
sourcecode/cli.py,sha256=Lq6Kbunzj01R5pbcDbrF8lMslaTpc4JbrLyVozCsstA,650907
|
|
23
23
|
sourcecode/client_calls.py,sha256=5_sBfFVXi6RgGiyWrlJKH4x4EF21do6gKlAOuFCKAoA,17709
|
|
24
24
|
sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
|
|
25
25
|
sourcecode/compare.py,sha256=2GdDy0qkDSgmkkpgvoCHitoLj3mt30EeFgJ8PGEXJng,14712
|
|
@@ -42,7 +42,7 @@ sourcecode/declarations.py,sha256=Jr7oJDg004sFw0TvJySNVggXFT2jB6hQgbeQK2qreVo,70
|
|
|
42
42
|
sourcecode/defect_identity.py,sha256=XS2VFDImg9NLaRImybKTeyk-CoDSsXmchldg0dwvM7c,7028
|
|
43
43
|
sourcecode/degradation.py,sha256=BtuGUKFgkzI3Z6fwmUdxEUEaLFAcPQ_bHhU2w9ItDxw,18376
|
|
44
44
|
sourcecode/dependency_analyzer.py,sha256=MRv6eh1TG5E9Y5Db-bNdZHDIJ3gWvvL3bBU1-9_QSZI,64593
|
|
45
|
-
sourcecode/deployment_prefix.py,sha256=
|
|
45
|
+
sourcecode/deployment_prefix.py,sha256=kPHPT6ZLg6CnMqCp9QwhbNNjDyS0sXAfKlPtSHUMggs,12180
|
|
46
46
|
sourcecode/detach.py,sha256=PvkCcbIuTaDKKt5TGqtQLahmsmCkflui33jLnMEWkvg,8006
|
|
47
47
|
sourcecode/doc_analyzer.py,sha256=05bjTUbDbmnbajD_cgRnACzS8T7xxBKVX4CjkJlhZg8,24411
|
|
48
48
|
sourcecode/dynamic_argument_surface.py,sha256=u6zJoY-zChYVw1-SrgiyAq-rUwqMZ5ZqBwLfbPJ_8ng,2960
|
|
@@ -106,7 +106,7 @@ sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcr
|
|
|
106
106
|
sourcecode/remedies.py,sha256=V2_cD4pLq8bKRkzF-3RGFoiu1xWYZS384WDVp1MrlZw,10225
|
|
107
107
|
sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
|
|
108
108
|
sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
|
|
109
|
-
sourcecode/repository_ir.py,sha256=
|
|
109
|
+
sourcecode/repository_ir.py,sha256=nSStGh9z8uHSTj92IJp85Y1J8NDGqlY59KtEsgHThqY,388812
|
|
110
110
|
sourcecode/ris.py,sha256=Xbx34dkJI_uXil9xsgw-fA_Apcm4nbnTjQgFm66dP1g,27489
|
|
111
111
|
sourcecode/risk.py,sha256=u2V7nG5yGW8VV4sFDSgFkZqdI6ltmilWhlQwlIH7d2o,82660
|
|
112
112
|
sourcecode/rule_catalog.py,sha256=pTkgkQZ1u7atB3fkQJ9BPWo8lNiamvEuRN7MtRgle0c,5263
|
|
@@ -118,14 +118,14 @@ sourcecode/scanner.py,sha256=z3CV0rcGunu0Y8mpNgp07wI7nxT0pxw1BkXRRtI0Rpo,9609
|
|
|
118
118
|
sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
|
|
119
119
|
sourcecode/security_chain.py,sha256=1YZVeFLwy65kntMBhMNvz1cUpOoKCmvEqYICgxs66Gw,6823
|
|
120
120
|
sourcecode/security_config.py,sha256=LUppRDYQK0oKM4qdEOLObaeU3frFTS-paEZo7dMgtY8,5467
|
|
121
|
-
sourcecode/security_config_scan.py,sha256=
|
|
121
|
+
sourcecode/security_config_scan.py,sha256=iXDeulgdbAwUFR01p9PgrJED2vd4HE2FCZgqrxX5D0E,32824
|
|
122
122
|
sourcecode/security_posture.py,sha256=FiEGood01y5gKhuYyL6NK4VT-WtCUMJwHOSR5xTfnPI,54063
|
|
123
123
|
sourcecode/semantic_analyzer.py,sha256=bpgdC6m0_ftVtRf3rSdwhbhWjnZnGxRXaZVcfe4BbcQ,95414
|
|
124
124
|
sourcecode/semantic_impact_engine.py,sha256=t09IirGC3JjQDy33JZd1_WKzQVKXkoNl3-XEUr5kjis,20563
|
|
125
125
|
sourcecode/semantic_integration_engine.py,sha256=7a0WqAInOv39f0Yr_94TYo_JP_8QpeI9KGaknpzAyQU,18899
|
|
126
126
|
sourcecode/semantic_services.py,sha256=nbUuPv-F01USTt_9CHT8iy_ucCIw3fz4W3Aquea_pd4,10782
|
|
127
127
|
sourcecode/serializer.py,sha256=cK4I3WzX0H-oBPbqzBsUBRux5GcYmH_yPDQwxvkf8yQ,141598
|
|
128
|
-
sourcecode/servlet_surface.py,sha256=
|
|
128
|
+
sourcecode/servlet_surface.py,sha256=1ZB76G5PrIGqmRAO8rEd20SMYoexZ5t01v82HtHLSXo,12481
|
|
129
129
|
sourcecode/source_text.py,sha256=R52gmSljz7L6YVPbixv5W-LOZ5eKUVvSUP3S-QH_IWc,9047
|
|
130
130
|
sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
|
|
131
131
|
sourcecode/spring_findings.py,sha256=cW6NiQAFUM9mDYPitJr6MrQg--YBI41LxrSEMI9aeIQ,25272
|
|
@@ -147,7 +147,7 @@ sourcecode/token_estimate.py,sha256=G-ivzIJfVq5TLQN04dC0WK4LZJjWVYL54Ebcxe4fJ7M,
|
|
|
147
147
|
sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
|
|
148
148
|
sourcecode/type_usage_surface.py,sha256=51IrKRQoIoRnlsiDjHnqpJBn2rc6E59aRhgS0HTzAF0,4428
|
|
149
149
|
sourcecode/validation_inference.py,sha256=-oWJqE6PqkqcZbCFDJeWCQHgI9Dbuv5ggJWD3LE_2IU,20817
|
|
150
|
-
sourcecode/validation_surface.py,sha256=
|
|
150
|
+
sourcecode/validation_surface.py,sha256=diiAyPiLdvmtTYrx9EywDXHG8odg9gITmN4od8Tj3JQ,35390
|
|
151
151
|
sourcecode/verify_edit.py,sha256=SUaJeH5Icc54mutQx3-gh7i8U6d50cb_oxYc9SDUnHs,42757
|
|
152
152
|
sourcecode/verify_repo.py,sha256=aHlNIUfoVVUxTXTNtSFryP4dOFBOvCfdyC31fFRXU6s,15346
|
|
153
153
|
sourcecode/verify_rules.py,sha256=R3l6nLT6WQzUNBzxtmnxyGZcKj9qMDW38Y0c0gNdCGk,19973
|
|
@@ -213,10 +213,10 @@ sourcecode/telemetry/consent.py,sha256=pQdl-QeLl6Gcibn0eWHSKZrm-HYSsjpVqOnjrgFp8
|
|
|
213
213
|
sourcecode/telemetry/events.py,sha256=4_yeO58U-Cwc1Qb27VB0_EjhmroY0k91n3_VGxeALB8,2776
|
|
214
214
|
sourcecode/telemetry/filters.py,sha256=RzxauTz8HliO4BllQnXEXc7zTeqdCZi5MgqGEDuW7OQ,6570
|
|
215
215
|
sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
|
|
216
|
-
sourcecode/_docs/DEFECT-LEDGER.md,sha256=
|
|
217
|
-
sourcecode/_docs/USER_GUIDE.md,sha256=
|
|
218
|
-
sourcecode-5.5.
|
|
219
|
-
sourcecode-5.5.
|
|
220
|
-
sourcecode-5.5.
|
|
221
|
-
sourcecode-5.5.
|
|
222
|
-
sourcecode-5.5.
|
|
216
|
+
sourcecode/_docs/DEFECT-LEDGER.md,sha256=5SxWnKt6p5ZRmx-WsFhIlMfe7-e5fLF0QRa9lAmzYug,499071
|
|
217
|
+
sourcecode/_docs/USER_GUIDE.md,sha256=ykjudytfe1VLgGJQAcs36wkXsEO3N-QJY2js1cJc7y0,79837
|
|
218
|
+
sourcecode-5.5.5.dist-info/METADATA,sha256=bMkQLLoD1zYBemJ5ZJpHyy9xIurRVdVOTCP20jSLN6I,47104
|
|
219
|
+
sourcecode-5.5.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
220
|
+
sourcecode-5.5.5.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
|
|
221
|
+
sourcecode-5.5.5.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
|
|
222
|
+
sourcecode-5.5.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|