sourcecode 1.36.1__py3-none-any.whl → 1.36.3__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/canonical_ir.py +12 -0
- sourcecode/cli.py +22 -1
- sourcecode/repository_ir.py +96 -13
- sourcecode/security_config.py +99 -0
- sourcecode/version_check.py +149 -0
- {sourcecode-1.36.1.dist-info → sourcecode-1.36.3.dist-info}/METADATA +18 -1
- {sourcecode-1.36.1.dist-info → sourcecode-1.36.3.dist-info}/RECORD +11 -9
- {sourcecode-1.36.1.dist-info → sourcecode-1.36.3.dist-info}/WHEEL +0 -0
- {sourcecode-1.36.1.dist-info → sourcecode-1.36.3.dist-info}/entry_points.txt +0 -0
- {sourcecode-1.36.1.dist-info → sourcecode-1.36.3.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/canonical_ir.py
CHANGED
|
@@ -59,6 +59,9 @@ class CanonicalSecurity:
|
|
|
59
59
|
effective_roles: list[str] = field(default_factory=list)
|
|
60
60
|
expression: str = "" # SpEL for @PreAuthorize/@PostAuthorize
|
|
61
61
|
required_permission: str = "" # for custom permission annotations
|
|
62
|
+
annotation: str = "" # custom security annotation short name (BUG-3)
|
|
63
|
+
resource_name: str = "" # resource guarded by custom annotation
|
|
64
|
+
required_level: str = "" # access level required by custom annotation
|
|
62
65
|
raw: dict = field(default_factory=dict) # full original policy dict
|
|
63
66
|
|
|
64
67
|
def to_dict(self) -> dict:
|
|
@@ -70,6 +73,12 @@ class CanonicalSecurity:
|
|
|
70
73
|
out["expression"] = self.expression
|
|
71
74
|
if self.required_permission:
|
|
72
75
|
out["required_permission"] = self.required_permission
|
|
76
|
+
if self.annotation:
|
|
77
|
+
out["annotation"] = self.annotation
|
|
78
|
+
if self.resource_name:
|
|
79
|
+
out["resourceName"] = self.resource_name
|
|
80
|
+
if self.required_level:
|
|
81
|
+
out["requiredLevel"] = self.required_level
|
|
73
82
|
return out
|
|
74
83
|
|
|
75
84
|
def to_full_dict(self) -> dict:
|
|
@@ -89,6 +98,9 @@ class CanonicalSecurity:
|
|
|
89
98
|
effective_roles=list(d.get("roles", [])),
|
|
90
99
|
expression=d.get("expression", ""),
|
|
91
100
|
required_permission=d.get("required_permission", ""),
|
|
101
|
+
annotation=d.get("annotation", ""),
|
|
102
|
+
resource_name=d.get("resourceName", ""),
|
|
103
|
+
required_level=d.get("requiredLevel", ""),
|
|
92
104
|
raw=dict(d),
|
|
93
105
|
)
|
|
94
106
|
|
sourcecode/cli.py
CHANGED
|
@@ -3853,6 +3853,18 @@ def endpoints_cmd(
|
|
|
3853
3853
|
and JAX-RS (@GET/@POST/@PUT/@DELETE/@PATCH with @Path) annotations.
|
|
3854
3854
|
Extracts HTTP method, path, controller class, and handler method.
|
|
3855
3855
|
|
|
3856
|
+
\b
|
|
3857
|
+
Custom security annotations: add a sourcecode.config.json at the repo root to
|
|
3858
|
+
teach the scanner project-specific authorization annotations (otherwise reported
|
|
3859
|
+
as policy "none_detected"):
|
|
3860
|
+
{
|
|
3861
|
+
"customSecurityAnnotations": [
|
|
3862
|
+
{"shortName": "M3FiltroSeguridad",
|
|
3863
|
+
"resourceParam": "nombreRecurso", "levelParam": "nivelRequerido"}
|
|
3864
|
+
]
|
|
3865
|
+
}
|
|
3866
|
+
Matching endpoints report policy "custom" with annotation/resourceName/requiredLevel.
|
|
3867
|
+
|
|
3856
3868
|
\b
|
|
3857
3869
|
Examples:
|
|
3858
3870
|
sourcecode endpoints .
|
|
@@ -6212,4 +6224,13 @@ def main_entry() -> None:
|
|
|
6212
6224
|
except Exception:
|
|
6213
6225
|
pass
|
|
6214
6226
|
_preprocess_argv()
|
|
6215
|
-
|
|
6227
|
+
try:
|
|
6228
|
+
app()
|
|
6229
|
+
finally:
|
|
6230
|
+
# Best-effort "new version available" nudge. Only speaks on an
|
|
6231
|
+
# interactive terminal; never blocks, raises, or affects exit status.
|
|
6232
|
+
try:
|
|
6233
|
+
from sourcecode.version_check import maybe_notify_update
|
|
6234
|
+
maybe_notify_update(__version__)
|
|
6235
|
+
except Exception:
|
|
6236
|
+
pass
|
sourcecode/repository_ir.py
CHANGED
|
@@ -24,6 +24,11 @@ from typing import Any, Optional
|
|
|
24
24
|
|
|
25
25
|
from sourcecode.fqn_utils import normalize_owner_fqn as _normalize_owner_fqn
|
|
26
26
|
from sourcecode.path_filters import is_test_path as _is_test_path
|
|
27
|
+
from sourcecode.security_config import (
|
|
28
|
+
CustomSecuritySpec,
|
|
29
|
+
capture_markers as _capture_markers,
|
|
30
|
+
load_custom_security as _load_custom_security,
|
|
31
|
+
)
|
|
27
32
|
|
|
28
33
|
# ---------------------------------------------------------------------------
|
|
29
34
|
# Data classes — Phases 1–4
|
|
@@ -595,9 +600,18 @@ def _resolve_types_from_text(text: str, import_map: dict[str, str]) -> list[str]
|
|
|
595
600
|
# Phase 1 — Symbol extraction
|
|
596
601
|
# ---------------------------------------------------------------------------
|
|
597
602
|
|
|
598
|
-
def _extract_symbols(
|
|
603
|
+
def _extract_symbols(
|
|
604
|
+
source: str,
|
|
605
|
+
rel_path: str,
|
|
606
|
+
*,
|
|
607
|
+
extra_capture: "frozenset[str]" = frozenset(),
|
|
608
|
+
) -> tuple[str, list[SymbolRecord], list[str]]:
|
|
599
609
|
"""Phase 1: Extract symbols from a Java source file.
|
|
600
610
|
|
|
611
|
+
extra_capture: extra annotation tokens (e.g. custom security annotations like
|
|
612
|
+
"@M3FiltroSeguridad") whose argument lists must be stored in annotation_values
|
|
613
|
+
even though they are not in the built-in _CAPTURE_ANN_ARGS set.
|
|
614
|
+
|
|
601
615
|
Returns (package, symbols, raw_imports).
|
|
602
616
|
"""
|
|
603
617
|
package = ""
|
|
@@ -675,7 +689,7 @@ def _extract_symbols(source: str, rel_path: str) -> tuple[str, list[SymbolRecord
|
|
|
675
689
|
if ann:
|
|
676
690
|
if ann not in pending_anns:
|
|
677
691
|
pending_anns.append(ann)
|
|
678
|
-
if ann_args and ann in _CAPTURE_ANN_ARGS:
|
|
692
|
+
if ann_args and (ann in _CAPTURE_ANN_ARGS or ann in extra_capture):
|
|
679
693
|
# P1 fix: attempt to resolve constant expressions before storing.
|
|
680
694
|
# Transforms '"/" + SECTION_KEY' → '"/category"' when constant
|
|
681
695
|
# is defined in this file. Falls back to original if unresolvable.
|
|
@@ -2225,6 +2239,7 @@ def _assemble(
|
|
|
2225
2239
|
changed_symbols: list[ChangedSymbol],
|
|
2226
2240
|
spring_summary: dict, # noqa: ARG001 — used internally via _spring_role on symbols
|
|
2227
2241
|
route_diffs: list[dict] | None = None,
|
|
2242
|
+
custom_security: "tuple[CustomSecuritySpec, ...]" = (),
|
|
2228
2243
|
) -> dict:
|
|
2229
2244
|
"""Phase 5: Final assembly — single deterministic output contract."""
|
|
2230
2245
|
sorted_syms = sorted(symbols, key=lambda s: s.symbol)
|
|
@@ -2485,7 +2500,9 @@ def _assemble(
|
|
|
2485
2500
|
e.from_symbol: e.to_symbol.split(".")[-1]
|
|
2486
2501
|
for e in sorted_rels if e.type == "extends"
|
|
2487
2502
|
}
|
|
2488
|
-
_route_surface = _build_route_surface(
|
|
2503
|
+
_route_surface = _build_route_surface(
|
|
2504
|
+
sorted_syms, route_diffs, extends_map=_extends_map, custom_security=custom_security
|
|
2505
|
+
)
|
|
2489
2506
|
_analysis_gaps = _compute_analysis_gaps(sorted_syms, spring_summary, _route_surface, sorted_rels)
|
|
2490
2507
|
|
|
2491
2508
|
# Detect filter-based security model for the assembled IR.
|
|
@@ -2536,9 +2553,29 @@ def _assemble(
|
|
|
2536
2553
|
# Route surface security extraction
|
|
2537
2554
|
# ---------------------------------------------------------------------------
|
|
2538
2555
|
|
|
2556
|
+
def _custom_ann_param(raw: str, key: str) -> str:
|
|
2557
|
+
"""Extract `key = value` from a raw annotation argument string.
|
|
2558
|
+
|
|
2559
|
+
Prefers a quoted string literal; falls back to a bare token (constant ref
|
|
2560
|
+
such as ``SeguridadRecursosConst.RRHH_MOVADMINISTRATIVOS``). Returns "" when
|
|
2561
|
+
the key is absent.
|
|
2562
|
+
"""
|
|
2563
|
+
import re as _re
|
|
2564
|
+
if not key:
|
|
2565
|
+
return ""
|
|
2566
|
+
m = _re.search(rf'\b{_re.escape(key)}\s*=\s*"([^"]+)"', raw)
|
|
2567
|
+
if m:
|
|
2568
|
+
return m.group(1)
|
|
2569
|
+
m = _re.search(rf'\b{_re.escape(key)}\s*=\s*([A-Za-z_][\w.]*)', raw)
|
|
2570
|
+
if m:
|
|
2571
|
+
return m.group(1)
|
|
2572
|
+
return ""
|
|
2573
|
+
|
|
2574
|
+
|
|
2539
2575
|
def _route_security_from_sym(
|
|
2540
2576
|
method_sym: "Optional[SymbolRecord]",
|
|
2541
2577
|
class_sym: "Optional[SymbolRecord]",
|
|
2578
|
+
custom_security: "tuple[CustomSecuritySpec, ...]" = (),
|
|
2542
2579
|
) -> "Optional[dict]":
|
|
2543
2580
|
"""Extract security policy from method and/or class-level annotations.
|
|
2544
2581
|
|
|
@@ -2557,6 +2594,10 @@ def _route_security_from_sym(
|
|
|
2557
2594
|
@RequiresRoles → {policy: requiresroles, roles: [...]}
|
|
2558
2595
|
@RequiresPermissions → {policy: requirespermissions, roles: [...]}
|
|
2559
2596
|
@SecurityRequirement → {policy: openapi_security, spec: ...}
|
|
2597
|
+
<custom> → {policy: custom, annotation, resourceName?, requiredLevel?}
|
|
2598
|
+
|
|
2599
|
+
custom_security: project-defined security annotations from sourcecode.config.json
|
|
2600
|
+
(BUG-3). Checked after the built-in set so standard annotations always win.
|
|
2560
2601
|
|
|
2561
2602
|
Falls back to class-level annotations if no method-level security found.
|
|
2562
2603
|
Returns None if no security signal detected at either level.
|
|
@@ -2595,6 +2636,20 @@ def _route_security_from_sym(
|
|
|
2595
2636
|
if "@SecurityRequirement" in anns:
|
|
2596
2637
|
raw = vals.get("@SecurityRequirement", "")
|
|
2597
2638
|
return {"policy": "openapi_security", "spec": raw.strip()}
|
|
2639
|
+
# Project-defined custom security annotations (BUG-3).
|
|
2640
|
+
for spec in custom_security:
|
|
2641
|
+
if spec.marker in anns:
|
|
2642
|
+
raw = vals.get(spec.marker, "")
|
|
2643
|
+
out: dict = {"policy": "custom", "annotation": spec.short_name}
|
|
2644
|
+
res = _custom_ann_param(raw, spec.resource_param)
|
|
2645
|
+
lvl = _custom_ann_param(raw, spec.level_param)
|
|
2646
|
+
if res:
|
|
2647
|
+
out["resourceName"] = res
|
|
2648
|
+
if lvl:
|
|
2649
|
+
out["requiredLevel"] = lvl
|
|
2650
|
+
if spec.risk_level and spec.risk_level != "custom":
|
|
2651
|
+
out["riskLevel"] = spec.risk_level
|
|
2652
|
+
return out
|
|
2598
2653
|
return None
|
|
2599
2654
|
|
|
2600
2655
|
# Method-level first, then class-level fallback
|
|
@@ -2614,6 +2669,7 @@ def _build_route_surface(
|
|
|
2614
2669
|
symbols: list[SymbolRecord],
|
|
2615
2670
|
route_diffs: Optional[list[dict]],
|
|
2616
2671
|
extends_map: Optional[dict[str, str]] = None,
|
|
2672
|
+
custom_security: "tuple[CustomSecuritySpec, ...]" = (),
|
|
2617
2673
|
) -> list[dict]:
|
|
2618
2674
|
"""Return route surface with inheritance projection and JAX-RS sub-resource locator resolution.
|
|
2619
2675
|
|
|
@@ -2719,7 +2775,7 @@ def _build_route_surface(
|
|
|
2719
2775
|
|
|
2720
2776
|
# P1 FIX: extract security annotations (method-level first, class fallback)
|
|
2721
2777
|
_cls_sym_for_sec = class_sym_by_simple.get(cls_simple)
|
|
2722
|
-
_sec = _route_security_from_sym(sym, _cls_sym_for_sec)
|
|
2778
|
+
_sec = _route_security_from_sym(sym, _cls_sym_for_sec, custom_security)
|
|
2723
2779
|
|
|
2724
2780
|
# Programmatic security fallback: scan controller file when no annotation found.
|
|
2725
2781
|
if _sec is None:
|
|
@@ -2857,16 +2913,24 @@ def build_repo_ir(
|
|
|
2857
2913
|
root: Path,
|
|
2858
2914
|
*,
|
|
2859
2915
|
since: Optional[str] = None,
|
|
2916
|
+
custom_security: "Optional[list[CustomSecuritySpec]]" = None,
|
|
2860
2917
|
) -> dict:
|
|
2861
2918
|
"""Build IR across multiple Java files in a repo.
|
|
2862
2919
|
|
|
2863
2920
|
Args:
|
|
2864
|
-
file_paths:
|
|
2865
|
-
root:
|
|
2866
|
-
since:
|
|
2921
|
+
file_paths: Relative paths to Java files to analyze.
|
|
2922
|
+
root: Absolute repo root.
|
|
2923
|
+
since: Git ref for symbol diff (e.g. "HEAD~1", "main").
|
|
2924
|
+
custom_security: Custom security annotation specs (BUG-3). When None,
|
|
2925
|
+
loaded from <root>/sourcecode.config.json.
|
|
2867
2926
|
|
|
2868
2927
|
Returns aggregated deterministic IR dict (schema_version=final-v1).
|
|
2869
2928
|
"""
|
|
2929
|
+
if custom_security is None:
|
|
2930
|
+
custom_security = _load_custom_security(root)
|
|
2931
|
+
_custom_sec_tuple = tuple(custom_security)
|
|
2932
|
+
_extra_capture = _capture_markers(custom_security)
|
|
2933
|
+
|
|
2870
2934
|
all_symbols: list[SymbolRecord] = []
|
|
2871
2935
|
all_relations: list[RelationEdge] = []
|
|
2872
2936
|
all_changed: list[ChangedSymbol] = []
|
|
@@ -2926,7 +2990,11 @@ def build_repo_ir(
|
|
|
2926
2990
|
continue
|
|
2927
2991
|
for _m in re.finditer(r'@interface\s+(\w+)', _src):
|
|
2928
2992
|
_custom_meta_markers.add(f"@{_m.group(1)}")
|
|
2929
|
-
|
|
2993
|
+
# Custom security annotations (BUG-3) are also pre-scan markers so files
|
|
2994
|
+
# whose only relevant annotation is a custom one aren't filtered out.
|
|
2995
|
+
_effective_markers = (
|
|
2996
|
+
_ANNOTATION_MARKERS + tuple(_custom_meta_markers) + tuple(_extra_capture)
|
|
2997
|
+
)
|
|
2930
2998
|
|
|
2931
2999
|
_per_file: list[tuple[str, str, str, list[str], list[SymbolRecord]]] = []
|
|
2932
3000
|
for rel_path in sorted(file_paths):
|
|
@@ -2955,7 +3023,9 @@ def build_repo_ir(
|
|
|
2955
3023
|
all_symbols.extend(_min_syms)
|
|
2956
3024
|
# No relations needed for non-annotated files
|
|
2957
3025
|
continue
|
|
2958
|
-
package, symbols, raw_imports = _extract_symbols(
|
|
3026
|
+
package, symbols, raw_imports = _extract_symbols(
|
|
3027
|
+
source, rel_path, extra_capture=_extra_capture
|
|
3028
|
+
)
|
|
2959
3029
|
all_symbols.extend(symbols)
|
|
2960
3030
|
_per_file.append((rel_path, source, package, raw_imports, symbols))
|
|
2961
3031
|
|
|
@@ -2977,7 +3047,9 @@ def build_repo_ir(
|
|
|
2977
3047
|
old_source = _get_git_old_content(root, rel_path, since)
|
|
2978
3048
|
|
|
2979
3049
|
if old_source is not None:
|
|
2980
|
-
_, old_symbols, _ = _extract_symbols(
|
|
3050
|
+
_, old_symbols, _ = _extract_symbols(
|
|
3051
|
+
old_source, rel_path, extra_capture=_extra_capture
|
|
3052
|
+
)
|
|
2981
3053
|
all_changed.extend(_diff_symbols(old_symbols, symbols))
|
|
2982
3054
|
all_route_diffs.extend(_diff_routes(old_symbols, symbols))
|
|
2983
3055
|
elif since and (_since_changed is None or rel_path in _since_changed):
|
|
@@ -3008,7 +3080,10 @@ def build_repo_ir(
|
|
|
3008
3080
|
route_diffs_arg: Optional[list[dict]] = (
|
|
3009
3081
|
sorted(all_route_diffs, key=lambda d: d["symbol"]) if since else None
|
|
3010
3082
|
)
|
|
3011
|
-
ir = _assemble(
|
|
3083
|
+
ir = _assemble(
|
|
3084
|
+
all_symbols, unique_relations, all_changed, spring_summary, route_diffs_arg,
|
|
3085
|
+
custom_security=_custom_sec_tuple,
|
|
3086
|
+
)
|
|
3012
3087
|
|
|
3013
3088
|
# BUG-7: XML Spring Security detection for the canonical CIR pipeline.
|
|
3014
3089
|
# _assemble only sees Java symbols — XML config is invisible to it.
|
|
@@ -3370,6 +3445,11 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
|
|
|
3370
3445
|
|
|
3371
3446
|
_EXTENDS_FROM_SIG = _re.compile(r'\bextends\s+(\w+)')
|
|
3372
3447
|
|
|
3448
|
+
# Custom security annotations (BUG-3): recognized via sourcecode.config.json.
|
|
3449
|
+
_custom_security = _load_custom_security(root)
|
|
3450
|
+
_custom_sec_tuple = tuple(_custom_security)
|
|
3451
|
+
_extra_capture = _capture_markers(_custom_security)
|
|
3452
|
+
|
|
3373
3453
|
# Exclude REST client proxy modules — they use JAX-RS annotations for client-side
|
|
3374
3454
|
# proxy generation (RESTEasy, MicroProfile REST Client) and are NOT server resources.
|
|
3375
3455
|
_CLIENT_PATH_FRAGMENTS = (
|
|
@@ -3394,7 +3474,7 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
|
|
|
3394
3474
|
rel = str(jf.relative_to(root)).replace("\\", "/")
|
|
3395
3475
|
except ValueError:
|
|
3396
3476
|
rel = str(jf).replace("\\", "/")
|
|
3397
|
-
_, symbols, _ = _extract_symbols(source, rel)
|
|
3477
|
+
_, symbols, _ = _extract_symbols(source, rel, extra_capture=_extra_capture)
|
|
3398
3478
|
for sym in symbols:
|
|
3399
3479
|
all_symbols.append(sym)
|
|
3400
3480
|
if sym.type in ("class", "interface"):
|
|
@@ -3402,7 +3482,10 @@ def extract_java_endpoints(root: Path) -> "dict[str, Any]":
|
|
|
3402
3482
|
if m:
|
|
3403
3483
|
extends_map[sym.symbol] = m.group(1)
|
|
3404
3484
|
|
|
3405
|
-
routes = _build_route_surface(
|
|
3485
|
+
routes = _build_route_surface(
|
|
3486
|
+
all_symbols, route_diffs=None, extends_map=extends_map,
|
|
3487
|
+
custom_security=_custom_sec_tuple,
|
|
3488
|
+
)
|
|
3406
3489
|
|
|
3407
3490
|
# Security extraction: _build_route_surface already calls _route_security_from_sym
|
|
3408
3491
|
# and stores the result as route["security_annotations"].
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Custom security annotation configuration (BUG-3).
|
|
2
|
+
|
|
3
|
+
Enterprise Spring projects routinely guard endpoints with bespoke authorization
|
|
4
|
+
annotations (e.g. ``@M3FiltroSeguridad(nombreRecurso=..., nivelRequerido=...)``)
|
|
5
|
+
instead of the standard ``@PreAuthorize`` / ``@Secured`` set. Without knowing
|
|
6
|
+
those names, the endpoint surface reports ``policy: none_detected`` for every
|
|
7
|
+
protected route, which makes ``endpoints`` / ``spring-audit`` blind in exactly
|
|
8
|
+
the repos that most need auditing.
|
|
9
|
+
|
|
10
|
+
This module loads ``sourcecode.config.json`` from a repo root and exposes the
|
|
11
|
+
custom annotation specs used by the canonical security extractor.
|
|
12
|
+
|
|
13
|
+
Best-effort by design: a missing file, malformed JSON, or unexpected shape all
|
|
14
|
+
yield an empty list, so repos without a config behave exactly as before.
|
|
15
|
+
|
|
16
|
+
Config shape::
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
"customSecurityAnnotations": [
|
|
20
|
+
{
|
|
21
|
+
"fullyQualifiedName": "com.example.security.M3FiltroSeguridad",
|
|
22
|
+
"shortName": "M3FiltroSeguridad",
|
|
23
|
+
"resourceParam": "nombreRecurso",
|
|
24
|
+
"levelParam": "nivelRequerido",
|
|
25
|
+
"riskLevel": "custom"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Optional
|
|
36
|
+
|
|
37
|
+
CONFIG_FILENAME = "sourcecode.config.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class CustomSecuritySpec:
|
|
42
|
+
"""One custom security annotation the analyzer should recognize."""
|
|
43
|
+
|
|
44
|
+
short_name: str # e.g. "M3FiltroSeguridad" (no leading @)
|
|
45
|
+
fqn: str = "" # fully-qualified name (optional)
|
|
46
|
+
resource_param: str = "" # annotation attribute naming the protected resource
|
|
47
|
+
level_param: str = "" # annotation attribute naming the required level
|
|
48
|
+
risk_level: str = "custom"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def marker(self) -> str:
|
|
52
|
+
"""Annotation token as it appears in source and SymbolRecord.annotations."""
|
|
53
|
+
return f"@{self.short_name}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_custom_security(root: Optional[Path]) -> list[CustomSecuritySpec]:
|
|
57
|
+
"""Load custom security specs from ``<root>/sourcecode.config.json``.
|
|
58
|
+
|
|
59
|
+
Returns [] for any error or absent config — never raises.
|
|
60
|
+
"""
|
|
61
|
+
if root is None:
|
|
62
|
+
return []
|
|
63
|
+
try:
|
|
64
|
+
cfg_path = root / CONFIG_FILENAME
|
|
65
|
+
if not cfg_path.is_file():
|
|
66
|
+
return []
|
|
67
|
+
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
68
|
+
except Exception:
|
|
69
|
+
return []
|
|
70
|
+
|
|
71
|
+
raw = data.get("customSecurityAnnotations") if isinstance(data, dict) else None
|
|
72
|
+
if not isinstance(raw, list):
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
specs: list[CustomSecuritySpec] = []
|
|
76
|
+
for item in raw:
|
|
77
|
+
if not isinstance(item, dict):
|
|
78
|
+
continue
|
|
79
|
+
short = str(item.get("shortName") or "").strip().lstrip("@")
|
|
80
|
+
fqn = str(item.get("fullyQualifiedName") or "").strip()
|
|
81
|
+
if not short and fqn:
|
|
82
|
+
short = fqn.rsplit(".", 1)[-1]
|
|
83
|
+
if not short:
|
|
84
|
+
continue
|
|
85
|
+
specs.append(
|
|
86
|
+
CustomSecuritySpec(
|
|
87
|
+
short_name=short,
|
|
88
|
+
fqn=fqn,
|
|
89
|
+
resource_param=str(item.get("resourceParam") or "").strip(),
|
|
90
|
+
level_param=str(item.get("levelParam") or "").strip(),
|
|
91
|
+
risk_level=str(item.get("riskLevel") or "custom").strip() or "custom",
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return specs
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def capture_markers(specs: "list[CustomSecuritySpec]") -> "frozenset[str]":
|
|
98
|
+
"""Annotation tokens whose argument lists must be captured during extraction."""
|
|
99
|
+
return frozenset(s.marker for s in specs)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Best-effort "new version available" nudge.
|
|
2
|
+
|
|
3
|
+
Prints a single stderr line when a newer release exists on PyPI. Designed to be
|
|
4
|
+
invisible unless it has something useful to say:
|
|
5
|
+
|
|
6
|
+
* Only runs in an interactive terminal (stderr.isatty()) — never pollutes
|
|
7
|
+
piped output, MCP stdio, CI logs, or test runners.
|
|
8
|
+
* Hits the network at most once per 24h (cached in
|
|
9
|
+
~/.sourcecode/version_check.json); warm runs read the cache and are instant.
|
|
10
|
+
* Re-shows the same nudge at most ~once per 20h so it informs without nagging.
|
|
11
|
+
* Swallows every error and never blocks meaningfully (1.5s network timeout).
|
|
12
|
+
|
|
13
|
+
Disable entirely with SOURCECODE_NO_UPDATE_CHECK=1 (also off under SOURCECODE_CI).
|
|
14
|
+
The check reads PyPI only; it never touches the license in ~/.sourcecode/license.json.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
_CACHE_DIR = Path.home() / ".sourcecode"
|
|
26
|
+
_CACHE_FILE = _CACHE_DIR / "version_check.json"
|
|
27
|
+
_PYPI_URL = "https://pypi.org/pypi/sourcecode/json"
|
|
28
|
+
_CHECK_TTL_SECONDS = 86_400 # refresh the PyPI lookup at most once per 24h
|
|
29
|
+
_NOTIFY_TTL_SECONDS = 72_000 # re-show the nudge at most every ~20h
|
|
30
|
+
_FETCH_TIMEOUT = 1.5
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _disabled() -> bool:
|
|
34
|
+
"""True when the nudge must stay silent (opt-out, CI, or non-interactive)."""
|
|
35
|
+
if os.environ.get("SOURCECODE_NO_UPDATE_CHECK"):
|
|
36
|
+
return True
|
|
37
|
+
if os.environ.get("SOURCECODE_CI"):
|
|
38
|
+
return True
|
|
39
|
+
try:
|
|
40
|
+
return not sys.stderr.isatty()
|
|
41
|
+
except Exception:
|
|
42
|
+
return True # no usable stderr -> stay silent
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_cache() -> dict:
|
|
46
|
+
try:
|
|
47
|
+
if _CACHE_FILE.exists():
|
|
48
|
+
return json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_cache(data: dict) -> None:
|
|
55
|
+
try:
|
|
56
|
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
tmp = _CACHE_FILE.with_suffix(".tmp")
|
|
58
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
59
|
+
tmp.replace(_CACHE_FILE)
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _age_seconds(iso: Optional[str]) -> float:
|
|
65
|
+
if not iso:
|
|
66
|
+
return float("inf")
|
|
67
|
+
try:
|
|
68
|
+
ts = datetime.fromisoformat(iso)
|
|
69
|
+
if ts.tzinfo is None:
|
|
70
|
+
ts = ts.replace(tzinfo=timezone.utc)
|
|
71
|
+
return (datetime.now(timezone.utc) - ts).total_seconds()
|
|
72
|
+
except Exception:
|
|
73
|
+
return float("inf")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _fetch_latest() -> Optional[str]:
|
|
77
|
+
import urllib.request
|
|
78
|
+
try:
|
|
79
|
+
req = urllib.request.Request(_PYPI_URL, headers={"Accept": "application/json"})
|
|
80
|
+
with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp:
|
|
81
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
82
|
+
return ((data.get("info") or {}).get("version")) or None
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse(v: str) -> tuple:
|
|
88
|
+
"""Lenient dotted-numeric parse for the fallback path (no packaging dep)."""
|
|
89
|
+
parts = []
|
|
90
|
+
for chunk in str(v).split("."):
|
|
91
|
+
num = ""
|
|
92
|
+
for ch in chunk:
|
|
93
|
+
if ch.isdigit():
|
|
94
|
+
num += ch
|
|
95
|
+
else:
|
|
96
|
+
break
|
|
97
|
+
parts.append(int(num) if num else 0)
|
|
98
|
+
return tuple(parts)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_newer(latest: str, current: str) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
from packaging.version import parse as _vparse # type: ignore
|
|
104
|
+
return _vparse(latest) > _vparse(current)
|
|
105
|
+
except Exception:
|
|
106
|
+
return _parse(latest) > _parse(current)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def maybe_notify_update(current_version: str) -> None:
|
|
110
|
+
"""Print an upgrade nudge to stderr if PyPI has a newer release.
|
|
111
|
+
|
|
112
|
+
Best-effort and fully guarded: any failure is silently ignored. Safe to call
|
|
113
|
+
unconditionally from the CLI entry point.
|
|
114
|
+
"""
|
|
115
|
+
if _disabled():
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
cache = _read_cache()
|
|
119
|
+
|
|
120
|
+
# Refresh the cached "latest" at most once per TTL (the only network hit).
|
|
121
|
+
if _age_seconds(cache.get("checked_at")) >= _CHECK_TTL_SECONDS:
|
|
122
|
+
latest = _fetch_latest()
|
|
123
|
+
if latest:
|
|
124
|
+
cache["latest"] = latest
|
|
125
|
+
cache["checked_at"] = datetime.now(timezone.utc).isoformat()
|
|
126
|
+
_write_cache(cache)
|
|
127
|
+
|
|
128
|
+
latest = cache.get("latest")
|
|
129
|
+
if not latest or not _is_newer(latest, current_version):
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Throttle: don't nag for the same version more than once per ~20h.
|
|
133
|
+
if (
|
|
134
|
+
cache.get("notified_for") == latest
|
|
135
|
+
and _age_seconds(cache.get("notified_at")) < _NOTIFY_TTL_SECONDS
|
|
136
|
+
):
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
sys.stderr.write(
|
|
140
|
+
f"\n[sourcecode] v{latest} is available (you have {current_version}). "
|
|
141
|
+
"Upgrade: pipx upgrade sourcecode (pip: pip install -U sourcecode)\n"
|
|
142
|
+
)
|
|
143
|
+
sys.stderr.flush()
|
|
144
|
+
|
|
145
|
+
cache["notified_for"] = latest
|
|
146
|
+
cache["notified_at"] = datetime.now(timezone.utc).isoformat()
|
|
147
|
+
_write_cache(cache)
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sourcecode
|
|
3
|
-
Version: 1.36.
|
|
3
|
+
Version: 1.36.3
|
|
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
|
|
@@ -365,6 +365,23 @@ sourcecode endpoints /path/to/repo --output endpoints.json
|
|
|
365
365
|
|
|
366
366
|
Extracts all Spring MVC (`@GetMapping`, `@PostMapping`, `@RequestMapping`, etc.) and JAX-RS (`@GET`, `@POST`, `@Path`) endpoint methods. Returns HTTP method, path, controller class, and handler method.
|
|
367
367
|
|
|
368
|
+
**Custom security annotations.** Enterprise repos often guard endpoints with a bespoke annotation instead of `@PreAuthorize`/`@Secured`. Drop a `sourcecode.config.json` at the repo root to teach the scanner about it — otherwise those endpoints report `policy: "none_detected"`:
|
|
369
|
+
|
|
370
|
+
```json
|
|
371
|
+
{
|
|
372
|
+
"customSecurityAnnotations": [
|
|
373
|
+
{
|
|
374
|
+
"fullyQualifiedName": "com.example.security.M3FiltroSeguridad",
|
|
375
|
+
"shortName": "M3FiltroSeguridad",
|
|
376
|
+
"resourceParam": "nombreRecurso",
|
|
377
|
+
"levelParam": "nivelRequerido"
|
|
378
|
+
}
|
|
379
|
+
]
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Matching endpoints then report `policy: "custom"` with `annotation`, `resourceName`, and `requiredLevel`, and are no longer counted in `no_security_signal`. Repos without the config behave exactly as before.
|
|
384
|
+
|
|
368
385
|
### `spring-audit` — Spring semantic audit [free]
|
|
369
386
|
|
|
370
387
|
```bash
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
sourcecode/__init__.py,sha256=
|
|
1
|
+
sourcecode/__init__.py,sha256=R2EBl2n5HISPyZl0TZ5WWj834XZILTkphQ_J6rBP3zA,103
|
|
2
2
|
sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
|
|
3
3
|
sourcecode/architecture_analyzer.py,sha256=liCwQmLgb5vplohy8arjYxs_HOIv5C9MjLh_gY6bc5Q,44115
|
|
4
4
|
sourcecode/architecture_summary.py,sha256=z34_6v7cSwy98cof2UVciGho7SCrZ93tiqMmq5WNzRQ,20405
|
|
5
5
|
sourcecode/ast_extractor.py,sha256=sa6CmLpn-k5G3_Hzxn8hAlZ5-TS-EVzXDD0Gvxd2jzs,50613
|
|
6
6
|
sourcecode/cache.py,sha256=1V3vsaODAa2UBJAC0xpvxpmRdriCezQx5Q8JCcfgziE,31892
|
|
7
|
-
sourcecode/canonical_ir.py,sha256=
|
|
7
|
+
sourcecode/canonical_ir.py,sha256=DEwucOPJguLsVtg5cV8mWXNi112l5jmBhv73KGGebVk,24849
|
|
8
8
|
sourcecode/cir_graphs.py,sha256=rZi8JV4ZrAa2WSCeyNa4JIEKQ_yZzDZTsrvVz2KfuKA,8919
|
|
9
9
|
sourcecode/classifier.py,sha256=hKzg-nQ47htqqIUzSGvYxv15cXrA3KgICTwJmdqal0o,8095
|
|
10
|
-
sourcecode/cli.py,sha256
|
|
10
|
+
sourcecode/cli.py,sha256=-E7iKh47hQyZGbhy1lM1bxCPUa21XW6zLHurByc7KGc,253149
|
|
11
11
|
sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
|
|
12
12
|
sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
|
|
13
13
|
sourcecode/context_scorer.py,sha256=QpChSpsmaAYz91rXA4Ue5xzQmNz_ZboZN09YOHScq1U,14679
|
|
@@ -42,11 +42,12 @@ sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
|
|
|
42
42
|
sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
|
|
43
43
|
sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
|
|
44
44
|
sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
|
|
45
|
-
sourcecode/repository_ir.py,sha256=
|
|
45
|
+
sourcecode/repository_ir.py,sha256=XtkzRTl3Ze3G6D2sXtXxlyxjkjSL2BuiKb8GQehkbdE,188320
|
|
46
46
|
sourcecode/ris.py,sha256=RcqLVwC-doFcKKViYDkCjZLBqf_wzLES7-F6vHEeWzE,20419
|
|
47
47
|
sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
|
|
48
48
|
sourcecode/scanner.py,sha256=WdOQ78mMzjR1NjmKTlbxdgwinnCTfAhxCVLBEFQiFHU,8899
|
|
49
49
|
sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
|
|
50
|
+
sourcecode/security_config.py,sha256=_m8oQAy2gP7Ho2I-IIMTFFjFVWbJIGlLEOiAWK2TDjs,3503
|
|
50
51
|
sourcecode/semantic_analyzer.py,sha256=4OdG6tTSnTvq3_dSWMbQu8Ad1ndSCKeG-b9qM4hIxkw,89176
|
|
51
52
|
sourcecode/serializer.py,sha256=TGzftrSKitZrtl6Hh-R05s4KdTOxwTmph_lGDbo2Wzg,125015
|
|
52
53
|
sourcecode/spring_event_topology.py,sha256=5_ON_21Le5zbG-1GRc5GLIi5HJfy_QjcXLVPC5WeUGQ,18055
|
|
@@ -58,6 +59,7 @@ sourcecode/spring_semantic.py,sha256=O1nKSGVzlukuxLHQVuCPxc-XrcrMFxwlHA20_dmEGgM
|
|
|
58
59
|
sourcecode/spring_tx_analyzer.py,sha256=FdFcyqPp3aT9oJ-PKrnXcTA6s69wdvzG-NBm0GMGPTU,30717
|
|
59
60
|
sourcecode/summarizer.py,sha256=zgdps7yS2IktAbWe7IWz0oUcr3QIuNPRGrsScbZ4R1g,21797
|
|
60
61
|
sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
|
|
62
|
+
sourcecode/version_check.py,sha256=CHp6ZxTIfo8kyHPCBgJA1uFC0xQCoXMuuOfrW8QTL8o,4942
|
|
61
63
|
sourcecode/workspace.py,sha256=X_6NmNnitvT3_38V-JDChydo_sR68s249hLFlrQskU0,8271
|
|
62
64
|
sourcecode/detectors/__init__.py,sha256=A0AACJFF6HWf_RgatNtWu3PUzstcKtIGM9f1PoFcJug,1987
|
|
63
65
|
sourcecode/detectors/base.py,sha256=C2EqfZudQ1ITK4DID4M70nPxqoh9bl1zn_ta6XRaGWs,1168
|
|
@@ -96,8 +98,8 @@ sourcecode/telemetry/consent.py,sha256=wLMvGNJeSSyZoNkQXpoUioY6mMv4Qdvuw7S9jAEWn
|
|
|
96
98
|
sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
|
|
97
99
|
sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
|
|
98
100
|
sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
|
|
99
|
-
sourcecode-1.36.
|
|
100
|
-
sourcecode-1.36.
|
|
101
|
-
sourcecode-1.36.
|
|
102
|
-
sourcecode-1.36.
|
|
103
|
-
sourcecode-1.36.
|
|
101
|
+
sourcecode-1.36.3.dist-info/METADATA,sha256=QGvZo4o1EjzCa6zRqOWBZQs7y9RjI1AEsVBrVc4BNvw,31056
|
|
102
|
+
sourcecode-1.36.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
103
|
+
sourcecode-1.36.3.dist-info/entry_points.txt,sha256=ex3F9rmbXeyDIoFQHtkEqTsKSaJow8F0LrVu8XfIktQ,57
|
|
104
|
+
sourcecode-1.36.3.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
|
|
105
|
+
sourcecode-1.36.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|