jscope 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jscope/__init__.py +1 -0
- jscope/__main__.py +10 -0
- jscope/ai/__init__.py +0 -0
- jscope/ai/explain.py +180 -0
- jscope/ai/providers.py +71 -0
- jscope/analyzers/__init__.py +0 -0
- jscope/analyzers/auth_analyzer.py +170 -0
- jscope/analyzers/authorization_analyzer.py +196 -0
- jscope/analyzers/base.py +56 -0
- jscope/analyzers/common.py +78 -0
- jscope/analyzers/dataflow_analyzer.py +192 -0
- jscope/analyzers/endpoint_analyzer.py +150 -0
- jscope/analyzers/framework_detector.py +82 -0
- jscope/analyzers/graphql_analyzer.py +198 -0
- jscope/analyzers/graphql_schema_analyzer.py +132 -0
- jscope/analyzers/html_analyzer.py +196 -0
- jscope/analyzers/manager.py +114 -0
- jscope/analyzers/openapi_analyzer.py +278 -0
- jscope/analyzers/router_analyzer.py +502 -0
- jscope/analyzers/secret_analyzer.py +99 -0
- jscope/analyzers/service_worker_analyzer.py +142 -0
- jscope/analyzers/source_map_analyzer.py +119 -0
- jscope/analyzers/storage_analyzer.py +125 -0
- jscope/analyzers/wasm_analyzer.py +139 -0
- jscope/assessment_engine.py +285 -0
- jscope/assessment_reporting.py +71 -0
- jscope/benchmark/__init__.py +1 -0
- jscope/benchmark/fixtures.py +105 -0
- jscope/benchmark/runner.py +155 -0
- jscope/cli.py +1241 -0
- jscope/config.py +72 -0
- jscope/core/__init__.py +0 -0
- jscope/core/artifacts.py +139 -0
- jscope/core/cache.py +198 -0
- jscope/core/classification.py +56 -0
- jscope/core/evidence.py +411 -0
- jscope/core/graph.py +196 -0
- jscope/core/redact.py +67 -0
- jscope/crawler/__init__.py +0 -0
- jscope/crawler/crawler.py +176 -0
- jscope/dataflow/__init__.py +1 -0
- jscope/dataflow/engine.py +1684 -0
- jscope/dataflow/modules.py +205 -0
- jscope/dataflow/rules.py +118 -0
- jscope/downloader/__init__.py +0 -0
- jscope/downloader/downloader.py +116 -0
- jscope/parsing/__init__.py +0 -0
- jscope/parsing/ast_engine.py +322 -0
- jscope/parsing/graphql_schema.py +153 -0
- jscope/parsing/html.py +55 -0
- jscope/parsing/queries.py +385 -0
- jscope/parsing/wasm.py +146 -0
- jscope/passive_discovery.py +63 -0
- jscope/pipeline.py +196 -0
- jscope/plugin/__init__.py +0 -0
- jscope/plugin/_worker.py +199 -0
- jscope/plugin/loader.py +225 -0
- jscope/plugin/sandbox.py +207 -0
- jscope/reporting/__init__.py +0 -0
- jscope/reporting/formats.py +208 -0
- jscope/reporting/human.py +137 -0
- jscope/reporting/json_report.py +35 -0
- jscope/reporting/priority.py +116 -0
- jscope/runtime/__init__.py +0 -0
- jscope/runtime/observe.py +397 -0
- jscope/runtime/prober.py +259 -0
- jscope/runtime/scope.py +288 -0
- jscope/security_transport.py +246 -0
- jscope/verify/__init__.py +1 -0
- jscope/verify/engine.py +290 -0
- jscope/workspace/__init__.py +0 -0
- jscope/workspace/assessment.py +133 -0
- jscope/workspace/common.py +25 -0
- jscope/workspace/diff.py +143 -0
- jscope/workspace/store.py +206 -0
- jscope-0.4.2.dist-info/METADATA +180 -0
- jscope-0.4.2.dist-info/RECORD +81 -0
- jscope-0.4.2.dist-info/WHEEL +5 -0
- jscope-0.4.2.dist-info/entry_points.txt +2 -0
- jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
- jscope-0.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""GraphQL Analyzer.
|
|
2
|
+
|
|
3
|
+
Extracts operations (queries, mutations, subscriptions), their variables and
|
|
4
|
+
root fields from three sources, strongest first:
|
|
5
|
+
1. precompiled documents {kind:"OperationDefinition", ...} (Apollo/codegen bundles)
|
|
6
|
+
2. gql`...` / graphql(`...`) tagged templates, and strings passed to client calls
|
|
7
|
+
3. bare string literals that parse as an operation (weakest, LITERAL only)
|
|
8
|
+
plus client/endpoint configuration, introspection use and persisted queries.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
16
|
+
from jscope.analyzers.common import PATH_RE, is_test_path
|
|
17
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
|
|
18
|
+
from jscope.parsing import queries as Q
|
|
19
|
+
|
|
20
|
+
# header may contain ONLY: name, (variables), @directives -- arbitrary prose before '{' is rejected
|
|
21
|
+
_OP_RE = re.compile(r"^\s*(query|mutation|subscription)\b\s*([A-Za-z_]\w*)?\s*(\([^)]*\))?\s*(?:@\w+(?:\([^)]*\))?\s*)*\{", re.S)
|
|
22
|
+
_VAR_RE = re.compile(r"\$(\w+)\s*:\s*([\w!\[\]]+)")
|
|
23
|
+
_TOKEN_RE = re.compile(r"\{|\}|\([^)]*\)|\.\.\.\s*\w*|[A-Za-z_]\w*(?:\s*:\s*[A-Za-z_]\w*)?")
|
|
24
|
+
_CLIENT_CALLS = {"request", "query", "mutate", "usequery", "usemutation", "uselazyquery", "usesubscription",
|
|
25
|
+
"graphql", "gql", "execute", "subscribe"}
|
|
26
|
+
_CLIENT_CTORS = {"apolloclient", "httplink", "createhttplink", "graphqlclient", "createclient", "urqlclient"}
|
|
27
|
+
_ENDPOINT_KEYS = ("uri", "url", "endpoint", "httpurl")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_operation(text: str) -> dict | None:
|
|
31
|
+
text = re.sub(r"\$\{[^}]*\}", "", text) # drop JS interpolations
|
|
32
|
+
m = _OP_RE.match(text)
|
|
33
|
+
if not m:
|
|
34
|
+
return None
|
|
35
|
+
op, name, vars_raw = m.group(1), m.group(2), m.group(3) or ""
|
|
36
|
+
variables = dict(_VAR_RE.findall(vars_raw))
|
|
37
|
+
depth, fields, i = 0, [], m.end() - 1
|
|
38
|
+
for tok in _TOKEN_RE.findall(text[i:]):
|
|
39
|
+
if tok == "{":
|
|
40
|
+
depth += 1
|
|
41
|
+
elif tok == "}":
|
|
42
|
+
depth -= 1
|
|
43
|
+
if depth == 0:
|
|
44
|
+
break
|
|
45
|
+
elif depth == 1 and not tok.startswith(("(", "...")):
|
|
46
|
+
fields.append(tok.split(":")[-1].strip())
|
|
47
|
+
if not fields:
|
|
48
|
+
return None
|
|
49
|
+
return {"operation": op, "name": name, "variables": variables, "root_fields": fields[:15]}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GraphQLAnalyzer(Analyzer):
|
|
53
|
+
name = "graphql_analyzer"
|
|
54
|
+
depends_on = ["framework_detector"]
|
|
55
|
+
produces_categories = ["graphql", "endpoint"]
|
|
56
|
+
|
|
57
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
58
|
+
cands: dict[tuple[str, str], Candidate] = {}
|
|
59
|
+
ctx.shared.setdefault("graphql_ops", {}) # presence == "graphql_analyzer ran"
|
|
60
|
+
|
|
61
|
+
def add(cat: str, subject: str, ev: list[Evidence], surface: str | None, test: str | None, meta: dict):
|
|
62
|
+
c = cands.get((cat, subject))
|
|
63
|
+
if c is None:
|
|
64
|
+
c = cands[(cat, subject)] = Candidate(category=cat, subject=subject, analyzer=self.name, evidence=[],
|
|
65
|
+
attack_surface=surface, suggested_test=test, metadata=dict(meta))
|
|
66
|
+
c.evidence += ev
|
|
67
|
+
if cat == "graphql" and meta.get("root_fields") is not None:
|
|
68
|
+
# shared fact for api_correlation: which schema root fields the client actually uses
|
|
69
|
+
dead = any(e.type == ET.DEAD_CODE for e in ev)
|
|
70
|
+
if not dead:
|
|
71
|
+
first = next((e for e in ev if e.file), None)
|
|
72
|
+
ctx.shared.setdefault("graphql_ops", {})[subject] = {
|
|
73
|
+
"operation": meta.get("operation"), "root_fields": list(meta.get("root_fields", [])),
|
|
74
|
+
"site": (first.file, first.line) if first else (None, None)}
|
|
75
|
+
|
|
76
|
+
for pf in ctx.files:
|
|
77
|
+
if pf.skipped_reason or pf.tree is None:
|
|
78
|
+
continue
|
|
79
|
+
bind = Q.bindings_for(ctx.ast, pf)
|
|
80
|
+
ops_in_file = 0
|
|
81
|
+
|
|
82
|
+
def base_ev(node, typ, desc):
|
|
83
|
+
ev = [Evidence(typ, desc, self.name, pf.path, pf.line_of(node), pf.node_text(node)[:100].replace("\n", " "))]
|
|
84
|
+
if ctx.ast.is_in_dead_branch(pf, node):
|
|
85
|
+
ev.append(Evidence(ET.DEAD_CODE, "sits in a statically dead branch", self.name))
|
|
86
|
+
if is_test_path(pf.path):
|
|
87
|
+
ev.append(Evidence(ET.TEST_FIXTURE_CONTEXT, "file looks like a test/mock", self.name))
|
|
88
|
+
return ev
|
|
89
|
+
|
|
90
|
+
def emit(info: dict, ev: list[Evidence]):
|
|
91
|
+
nonlocal ops_in_file
|
|
92
|
+
ops_in_file += 1
|
|
93
|
+
label = info["name"] or f"anonymous({','.join(info['root_fields'][:3])})"
|
|
94
|
+
subject = f"{info['operation']}:{label}"
|
|
95
|
+
id_vars = [v for v, t in info["variables"].items() if t.rstrip("!").startswith("ID") or re.search(r"id$", v, re.I)]
|
|
96
|
+
if info["operation"] == "mutation":
|
|
97
|
+
test = ("Call this mutation as an unauthenticated and a low-privilege user; check field-level authorization, "
|
|
98
|
+
"mass assignment through the input type, and rate limits.")
|
|
99
|
+
elif info["operation"] == "subscription":
|
|
100
|
+
test = "Subscribe as another tenant/user and check for cross-account event leakage."
|
|
101
|
+
else:
|
|
102
|
+
test = "Run as another account and without auth; check object-level authorization on every root field."
|
|
103
|
+
if id_vars:
|
|
104
|
+
test += f" Variable(s) {', '.join('$' + v for v in id_vars)} look like object IDs: swap in another account's IDs (BOLA/IDOR)."
|
|
105
|
+
add("graphql", subject, ev, "graphql", test,
|
|
106
|
+
{**info, "id_like_variables": id_vars})
|
|
107
|
+
|
|
108
|
+
for node in ctx.ast.walk(pf.tree.root_node):
|
|
109
|
+
# 1) precompiled document objects
|
|
110
|
+
if node.type == "object":
|
|
111
|
+
props = Q.object_props(pf, node)
|
|
112
|
+
if "kind" in props and pf.node_text(props["kind"]).strip("'\"") == "OperationDefinition" and "operation" in props:
|
|
113
|
+
op = pf.node_text(props["operation"]).strip("'\"")
|
|
114
|
+
nm = None
|
|
115
|
+
if "name" in props:
|
|
116
|
+
nm = pf.node_text(Q.object_props(pf, props["name"]).get("value") or props["name"]).strip("'\"")
|
|
117
|
+
nm = nm if re.fullmatch(r"\w+", nm or "") else None
|
|
118
|
+
fields = []
|
|
119
|
+
sel = Q.object_props(pf, props.get("selectionSet")).get("selections")
|
|
120
|
+
for item in Q.array_items(sel):
|
|
121
|
+
fp = Q.object_props(pf, item)
|
|
122
|
+
if "name" in fp:
|
|
123
|
+
v = Q.object_props(pf, fp["name"]).get("value")
|
|
124
|
+
if v is not None:
|
|
125
|
+
fields.append(pf.node_text(v).strip("'\""))
|
|
126
|
+
vs = {}
|
|
127
|
+
for item in Q.array_items(props.get("variableDefinitions")):
|
|
128
|
+
vp = Q.object_props(pf, item)
|
|
129
|
+
try:
|
|
130
|
+
vname = pf.node_text(Q.object_props(pf, Q.object_props(pf, vp["variable"])["name"])["value"]).strip("'\"")
|
|
131
|
+
vs[vname] = "?"
|
|
132
|
+
except KeyError:
|
|
133
|
+
pass
|
|
134
|
+
if op in ("query", "mutation", "subscription") and (nm or fields):
|
|
135
|
+
emit({"operation": op, "name": nm, "variables": vs, "root_fields": fields[:15]},
|
|
136
|
+
base_ev(node, ET.AST_PATTERN_MATCH, "precompiled GraphQL OperationDefinition document"))
|
|
137
|
+
continue
|
|
138
|
+
# 2) gql`...`
|
|
139
|
+
if node.type == "call_expression":
|
|
140
|
+
fn = node.child_by_field_name("function")
|
|
141
|
+
args = Q.call_args(node)
|
|
142
|
+
name = Q._callee_node_name(ctx.ast, pf, fn) if fn is not None else None
|
|
143
|
+
if name and Q.last_segment(name).lower() in ("gql", "graphql") and args and args[0].type == "template_string":
|
|
144
|
+
info = parse_operation(pf.node_text(args[0]).strip("`"))
|
|
145
|
+
if info:
|
|
146
|
+
emit(info, base_ev(node, ET.AST_PATTERN_MATCH, f"{name}`...` tagged template contains a {info['operation']}"))
|
|
147
|
+
# client configuration
|
|
148
|
+
if name and Q.last_segment(name).lower() in _CLIENT_CTORS and args and args[0].type == "object":
|
|
149
|
+
self._client_cfg(ctx, pf, node, args[0], add, base_ev)
|
|
150
|
+
elif node.type == "new_expression":
|
|
151
|
+
ctor = node.child_by_field_name("constructor")
|
|
152
|
+
args = Q.call_args(node)
|
|
153
|
+
if ctor is not None and pf.node_text(ctor).split(".")[-1].lower() in _CLIENT_CTORS and args and args[0].type == "object":
|
|
154
|
+
self._client_cfg(ctx, pf, node, args[0], add, base_ev)
|
|
155
|
+
# 3) bare strings
|
|
156
|
+
elif node.type in ("string", "template_string"):
|
|
157
|
+
raw = pf.node_text(node).strip("'\"`")
|
|
158
|
+
if "__schema" in raw or "IntrospectionQuery" in raw:
|
|
159
|
+
add("graphql", "introspection_query", base_ev(node, ET.LITERAL_STRING_MATCH, "introspection query text present"),
|
|
160
|
+
"graphql", "Check whether introspection is enabled in production; if so dump the schema and diff against what the UI uses.", {})
|
|
161
|
+
if "persistedQuery" in raw or "sha256Hash" in raw:
|
|
162
|
+
add("graphql", "persisted_queries", base_ev(node, ET.LITERAL_STRING_MATCH, "persisted-query / sha256Hash reference"),
|
|
163
|
+
"graphql", "Test whether the server accepts arbitrary query text or unregistered hashes (allow-list bypass).", {})
|
|
164
|
+
info = parse_operation(raw)
|
|
165
|
+
if info and node.parent is not None and not (node.parent.type == "arguments" and node.parent.parent is not None
|
|
166
|
+
and node.parent.parent.type == "call_expression"
|
|
167
|
+
and Q.last_segment(Q._callee_node_name(ctx.ast, pf, node.parent.parent.child_by_field_name("function")) or "").lower() in ("gql", "graphql")):
|
|
168
|
+
ev = base_ev(node, ET.LITERAL_STRING_MATCH, "string literal parses as a GraphQL operation")
|
|
169
|
+
par, ctxual = node.parent, False
|
|
170
|
+
if par.type == "pair" and Q.key_name(pf, par.child_by_field_name("key")) in ("query", "mutation", "subscription", "document"):
|
|
171
|
+
ctxual = True
|
|
172
|
+
elif par.type == "arguments" and par.parent is not None and par.parent.type == "call_expression":
|
|
173
|
+
nm = Q._callee_node_name(ctx.ast, pf, par.parent.child_by_field_name("function")) or ""
|
|
174
|
+
ctxual = Q.last_segment(nm).lower() in _CLIENT_CALLS
|
|
175
|
+
if ctxual:
|
|
176
|
+
ev.append(Evidence(ET.AST_PATTERN_MATCH, "operation text is passed to a GraphQL client / sent as the `query` field", self.name, pf.path, pf.line_of(node)))
|
|
177
|
+
emit(info, ev)
|
|
178
|
+
|
|
179
|
+
# GraphQL transport calls in a file that actually contains operations
|
|
180
|
+
if ops_in_file:
|
|
181
|
+
for site in ctx.ast.find_calls(pf):
|
|
182
|
+
if site.args_resolved and site.args_resolved[0].value and PATH_RE.match(site.args_resolved[0].value) \
|
|
183
|
+
and "graphql" in site.args_resolved[0].value.lower():
|
|
184
|
+
p = site.args_resolved[0].value
|
|
185
|
+
add("endpoint", p, [Evidence(ET.AST_PATTERN_MATCH,
|
|
186
|
+
f"{site.callee}() to a GraphQL endpoint in a file defining {ops_in_file} operation(s)",
|
|
187
|
+
self.name, pf.path, site.line, site.args_raw[0][:100])],
|
|
188
|
+
"graphql", "Send introspection and batched/aliased queries; test per-field authorization.", {})
|
|
189
|
+
return list(cands.values())
|
|
190
|
+
|
|
191
|
+
def _client_cfg(self, ctx, pf, node, obj, add, base_ev) -> None:
|
|
192
|
+
props = Q.object_props(pf, obj)
|
|
193
|
+
for key in props:
|
|
194
|
+
if key.lower() in _ENDPOINT_KEYS:
|
|
195
|
+
rv = ctx.ast.resolve_value(pf, props[key], Q.bindings_for(ctx.ast, pf))
|
|
196
|
+
if rv.value and not rv.is_placeholder and PATH_RE.match(rv.value):
|
|
197
|
+
add("endpoint", rv.value, base_ev(node, ET.AST_PATTERN_MATCH, f"GraphQL client configured with {key}={rv.value}"),
|
|
198
|
+
"graphql", "Send introspection and batched/aliased queries; test per-field authorization.", {})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""GraphQL Schema Analyzer (Phase 4).
|
|
2
|
+
|
|
3
|
+
Reads a schema you already have (SDL file, or introspection JSON saved earlier) and correlates it with the operations the
|
|
4
|
+
client really sends (from graphql_analyzer):
|
|
5
|
+
|
|
6
|
+
* client operation + schema agree on a root field -> the client operation finding gains ARTIFACT_RESOLVED from this
|
|
7
|
+
analyzer, and with it CROSS_ANALYZER_AGREEMENT
|
|
8
|
+
* sensitive or write root fields no analyzed client operation uses -> `graphql_schema_field` with client_referenced=False
|
|
9
|
+
(the UI never calls it: test authorization directly)
|
|
10
|
+
* root fields missing an auth directive that >=3 sibling root fields carry -> `graphql_authz_gap` (absence-based, LOW)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import PurePosixPath
|
|
17
|
+
|
|
18
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
19
|
+
from jscope.analyzers.common import is_test_path
|
|
20
|
+
from jscope.core.artifacts import KIND_GRAPHQL_SCHEMA, Artifact, structured
|
|
21
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
|
|
22
|
+
from jscope.parsing.graphql_schema import parse_introspection, parse_sdl
|
|
23
|
+
|
|
24
|
+
_AUTH_DIRECTIVE = re.compile(r"(auth|hasrole|haspermission|permission|protected|private|rbac|authorize|requires|scope|guard)", re.I)
|
|
25
|
+
_SENSITIVE = [
|
|
26
|
+
("admin", re.compile(r"(admin|sudo|superuser|impersonat|grant|revoke|role|permission)", re.I)),
|
|
27
|
+
("internal", re.compile(r"(internal|private)", re.I)),
|
|
28
|
+
("debug", re.compile(r"(debug|diagnostic|_dump)", re.I)),
|
|
29
|
+
("payment", re.compile(r"(payment|refund|invoice|billing|charge|payout|subscription(?!s?$))", re.I)),
|
|
30
|
+
("export", re.compile(r"(export|download|backup)", re.I)),
|
|
31
|
+
("upload", re.compile(r"(upload|import)", re.I)),
|
|
32
|
+
("auth", re.compile(r"(password|token|apikey|api_key|secret|session|login|signin|resetpass)", re.I)),
|
|
33
|
+
]
|
|
34
|
+
_ID_ARG = re.compile(r"(^id$|id$)", re.I)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _sensitive(name: str) -> str | None:
|
|
38
|
+
for tag, rx in _SENSITIVE:
|
|
39
|
+
if rx.search(name):
|
|
40
|
+
return tag
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GraphQLSchemaAnalyzer(Analyzer):
|
|
45
|
+
name = "graphql_schema_analyzer"
|
|
46
|
+
depends_on = ["framework_detector"]
|
|
47
|
+
run_after = ["graphql_analyzer"]
|
|
48
|
+
produces_categories = ["graphql_schema", "graphql_schema_field", "graphql_authz_gap", "graphql"]
|
|
49
|
+
|
|
50
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
51
|
+
out: list[Candidate] = []
|
|
52
|
+
for art in ctx.artifacts_of(KIND_GRAPHQL_SCHEMA):
|
|
53
|
+
try:
|
|
54
|
+
out.extend(self._schema(art, ctx))
|
|
55
|
+
except Exception as exc: # noqa: BLE001
|
|
56
|
+
ctx.diagnostics.append(f"[graphql_schema_analyzer] {art.path}: {type(exc).__name__}: {exc}")
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
def _schema(self, art: Artifact, ctx: ProjectContext) -> list[Candidate]:
|
|
60
|
+
out: list[Candidate] = []
|
|
61
|
+
fname = PurePosixPath(art.path.replace("\\", "/")).name
|
|
62
|
+
penalty = [Evidence(ET.TEST_FIXTURE_CONTEXT, "schema lives in a test/fixture path", self.name)] if is_test_path(art.path) else []
|
|
63
|
+
doc = structured(art) if art.path.lower().endswith(".json") else None
|
|
64
|
+
introspection = isinstance(doc, dict)
|
|
65
|
+
schema = parse_introspection(doc) if introspection else parse_sdl(art.text)
|
|
66
|
+
types, roots = schema["types"], schema["roots"]
|
|
67
|
+
client_ops = ctx.shared.get("graphql_ops") or None # None => analyzer did not run OR found no client operations: no "unused" claims
|
|
68
|
+
used: dict[str, set[str]] = {"query": set(), "mutation": set(), "subscription": set()}
|
|
69
|
+
for op in (client_ops or {}).values():
|
|
70
|
+
used.setdefault(op.get("operation") or "query", set()).update(op.get("root_fields", []))
|
|
71
|
+
|
|
72
|
+
counts = {r: len(types.get(tn, {}).get("fields", {})) for r, tn in roots.items() if tn}
|
|
73
|
+
out.append(Candidate(
|
|
74
|
+
category="graphql_schema", subject=fname, analyzer=self.name,
|
|
75
|
+
evidence=[Evidence(ET.ARTIFACT_RESOLVED, f"GraphQL schema parsed ({'introspection JSON' if introspection else 'SDL'}; "
|
|
76
|
+
+ ", ".join(f"{k}={v}" for k, v in counts.items()) + f"; {len(types)} types)", self.name, art.path),
|
|
77
|
+
Evidence(ET.AST_PATTERN_MATCH, "root operation types located", self.name, art.path)] + penalty,
|
|
78
|
+
attack_surface="graphql" if introspection else None,
|
|
79
|
+
suggested_test=("This is an introspection result: check whether introspection is still enabled on the live endpoint, "
|
|
80
|
+
"and diff the live schema against this one for newly added fields." if introspection else None),
|
|
81
|
+
metadata={"file": art.path, "format": "introspection" if introspection else "sdl", "root_field_counts": counts,
|
|
82
|
+
"type_count": len(types)}))
|
|
83
|
+
|
|
84
|
+
for op_kind, tname in roots.items():
|
|
85
|
+
if not tname or tname not in types:
|
|
86
|
+
continue
|
|
87
|
+
fields = types[tname]["fields"]
|
|
88
|
+
guarded = [f for f, d in fields.items() if any(_AUTH_DIRECTIVE.search(x) for x in d["directives"])]
|
|
89
|
+
for fld, d in fields.items():
|
|
90
|
+
sens = _sensitive(fld)
|
|
91
|
+
id_args = [a for a, t in d["args"].items() if t.rstrip("!").startswith("ID") or _ID_ARG.search(a)]
|
|
92
|
+
interesting = op_kind in ("mutation", "subscription") or bool(sens) or bool(id_args)
|
|
93
|
+
referenced = (fld in used.get(op_kind, set())) if client_ops is not None else None
|
|
94
|
+
subject = f"{op_kind}:{fld}"
|
|
95
|
+
if interesting:
|
|
96
|
+
surface = sens or ("idor_candidate" if id_args else "graphql")
|
|
97
|
+
ev = [Evidence(ET.ARTIFACT_RESOLVED, f"root field defined in schema {fname}", self.name, art.path),
|
|
98
|
+
Evidence(ET.AST_PATTERN_MATCH, f"{tname}.{fld}({', '.join(f'{a}: {t}' for a, t in d['args'].items())}): {d['type']}", self.name, art.path)]
|
|
99
|
+
if sens:
|
|
100
|
+
ev.append(Evidence(ET.NAMING_CONVENTION, f"field name matches '{sens}' naming convention", self.name))
|
|
101
|
+
tests = []
|
|
102
|
+
if op_kind == "mutation":
|
|
103
|
+
tests.append("Call this mutation unauthenticated and as a low-privilege user; check field-level authorization, mass assignment through input types, and rate limits.")
|
|
104
|
+
if id_args:
|
|
105
|
+
tests.append(f"Argument(s) {', '.join(id_args)} look like object IDs: swap in another account's IDs (BOLA/IDOR).")
|
|
106
|
+
if referenced is False:
|
|
107
|
+
tests.append("No resolvable client operation uses this field, so the UI may never exercise it: authorization here is often an afterthought. Test it directly.")
|
|
108
|
+
out.append(Candidate(
|
|
109
|
+
category="graphql_schema_field", subject=subject, analyzer=self.name, evidence=ev + penalty,
|
|
110
|
+
attack_surface=surface, suggested_test=" ".join(tests) or None,
|
|
111
|
+
metadata={"schema_file": art.path, "args": d["args"], "returns": d["type"], "directives": d["directives"],
|
|
112
|
+
"client_referenced": referenced}))
|
|
113
|
+
if len(guarded) >= 3 and fld not in guarded and op_kind != "subscription":
|
|
114
|
+
out.append(Candidate(
|
|
115
|
+
category="graphql_authz_gap", subject=subject, analyzer=self.name,
|
|
116
|
+
evidence=[Evidence(ET.AST_PATTERN_MATCH, f"{tname}.{fld} has no auth-style directive, while {len(guarded)} sibling root field(s) do", self.name, art.path),
|
|
117
|
+
Evidence(ET.LITERAL_STRING_MATCH, "siblings carrying a directive: " + ", ".join(guarded[:4]), self.name)] + penalty,
|
|
118
|
+
attack_surface="authorization",
|
|
119
|
+
suggested_test="Authorization is declared per field here and this one has no declaration. Call it without credentials: a missing directive is a classic gap (the schema may enforce it elsewhere, e.g. in a resolver).",
|
|
120
|
+
metadata={"absence_based": True, "schema_file": art.path}))
|
|
121
|
+
|
|
122
|
+
# corroborate the client's own operation findings (-> CROSS_ANALYZER_AGREEMENT)
|
|
123
|
+
for subject, op in (client_ops or {}).items():
|
|
124
|
+
kind = op.get("operation") or "query"
|
|
125
|
+
tname = roots.get(kind)
|
|
126
|
+
fields = types.get(tname, {}).get("fields", {}) if tname else {}
|
|
127
|
+
known = [f for f in op.get("root_fields", []) if f in fields]
|
|
128
|
+
if known:
|
|
129
|
+
out.append(Candidate(
|
|
130
|
+
category="graphql", subject=subject, analyzer=self.name,
|
|
131
|
+
evidence=[Evidence(ET.ARTIFACT_RESOLVED, f"schema {fname} defines root field(s) {', '.join(known[:3])} on {tname}", self.name, art.path)] + penalty))
|
|
132
|
+
return out
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""HTML Analyzer (Phase 4).
|
|
2
|
+
|
|
3
|
+
Server-rendered and hybrid apps hide most of their attack surface in markup, not JS: forms, hidden fields, upload
|
|
4
|
+
inputs, third-party scripts. Findings stay evidence-first:
|
|
5
|
+
* forms become `form` findings and, when the action is a path, `endpoint` candidates -- so an endpoint reached by BOTH
|
|
6
|
+
a <form> and a JS call earns CROSS_ANALYZER_AGREEMENT like any other independent corroboration
|
|
7
|
+
* absence-based checks (no CSRF token) are tagged `absence_based` and are LOW by construction
|
|
8
|
+
* external-script-without-SRI is only reported when the page's own origin is knowable from the document
|
|
9
|
+
(<base>, canonical, og:url); offline we cannot otherwise tell "third party" from "same site", so we say nothing
|
|
10
|
+
Inline <script> bodies are NOT analyzed here: the pipeline turns them into ParsedFiles for the JS analyzers.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from pathlib import PurePosixPath
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
from bs4 import Comment
|
|
20
|
+
|
|
21
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
22
|
+
from jscope.analyzers.common import PATH_RE, classify, is_test_path
|
|
23
|
+
from jscope.core.artifacts import KIND_HTML
|
|
24
|
+
from jscope.core.evidence import Candidate, Evidence, EvidenceType as ET
|
|
25
|
+
from jscope.core.graph import EdgeKind, NodeKind
|
|
26
|
+
from jscope.parsing.html import soup
|
|
27
|
+
|
|
28
|
+
_CSRF_NAME = re.compile(r"(csrf|xsrf|authenticity_token|_token$|__requestverificationtoken|antiforgery)", re.I)
|
|
29
|
+
_CSRF_META = re.compile(r"(csrf|xsrf)", re.I)
|
|
30
|
+
_WRITE = {"post", "put", "patch", "delete"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _origin_host(doc) -> str | None:
|
|
34
|
+
for tag, attr, key in ((doc.find("base", href=True), "href", None),
|
|
35
|
+
(doc.find("link", rel="canonical", href=True), "href", None),
|
|
36
|
+
(doc.find("meta", property="og:url", content=True), "content", None)):
|
|
37
|
+
if tag:
|
|
38
|
+
host = urlparse(tag.get(attr, "")).netloc
|
|
39
|
+
if host:
|
|
40
|
+
return host.lower()
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _snippet(tag) -> str:
|
|
45
|
+
return re.sub(r"\s+", " ", str(tag))[:140]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HtmlAnalyzer(Analyzer):
|
|
49
|
+
name = "html_analyzer"
|
|
50
|
+
depends_on = ["framework_detector"]
|
|
51
|
+
produces_categories = ["form", "form_csrf", "cleartext_credentials", "html_resource", "csp", "html_comment", "endpoint"]
|
|
52
|
+
|
|
53
|
+
def analyze_project(self, ctx: ProjectContext) -> list[Candidate]:
|
|
54
|
+
out: list[Candidate] = []
|
|
55
|
+
for art in ctx.artifacts_of(KIND_HTML):
|
|
56
|
+
try:
|
|
57
|
+
out.extend(self._page(art, ctx))
|
|
58
|
+
except Exception as exc: # noqa: BLE001 one malformed page must not sink the rest
|
|
59
|
+
ctx.diagnostics.append(f"[html_analyzer] {art.path}: {type(exc).__name__}: {exc}")
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
def _page(self, art, ctx: ProjectContext) -> list[Candidate]:
|
|
64
|
+
doc = soup(art)
|
|
65
|
+
out: list[Candidate] = []
|
|
66
|
+
page = PurePosixPath(art.path.replace("\\", "/")).name
|
|
67
|
+
test = is_test_path(art.path)
|
|
68
|
+
g = ctx.graph
|
|
69
|
+
pid = f"page:{art.path}"
|
|
70
|
+
g.add_node(pid, NodeKind.PAGE, page, file=art.path)
|
|
71
|
+
|
|
72
|
+
def ev(typ, desc, node=None, snippet=None):
|
|
73
|
+
line = getattr(node, "sourceline", None) if node is not None else None
|
|
74
|
+
items = [Evidence(typ, desc, self.name, art.path, line, snippet or (_snippet(node) if node is not None else None))]
|
|
75
|
+
return items
|
|
76
|
+
|
|
77
|
+
def test_penalty():
|
|
78
|
+
return [Evidence(ET.TEST_FIXTURE_CONTEXT, "file looks like a test/mock fixture", self.name)] if test else []
|
|
79
|
+
|
|
80
|
+
has_csrf_meta = any(_CSRF_META.search(m.get("name", "")) for m in doc.find_all("meta"))
|
|
81
|
+
|
|
82
|
+
# ---- scripts -> graph (page loads file)
|
|
83
|
+
for tag in doc.find_all("script", src=True):
|
|
84
|
+
src = tag["src"].strip()
|
|
85
|
+
parsed = urlparse(src)
|
|
86
|
+
if not parsed.netloc:
|
|
87
|
+
target = PurePosixPath(parsed.path).name
|
|
88
|
+
for pf in ctx.files:
|
|
89
|
+
if pf.origin == "disk" and PurePosixPath(pf.path.replace("\\", "/")).name == target and target:
|
|
90
|
+
fid = f"file:{pf.path}"
|
|
91
|
+
g.add_node(fid, NodeKind.FILE, pf.path)
|
|
92
|
+
g.add_edge(pid, fid, EdgeKind.LOADS, self.name, "ARTIFACT_RESOLVED", art.path,
|
|
93
|
+
getattr(tag, "sourceline", None))
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
# ---- external scripts without SRI (only when our own origin is knowable)
|
|
97
|
+
host = _origin_host(doc)
|
|
98
|
+
if host:
|
|
99
|
+
for tag in list(doc.find_all("script", src=True)) + list(doc.find_all("link", href=True, rel="stylesheet")):
|
|
100
|
+
url = tag.get("src") or tag.get("href")
|
|
101
|
+
u = urlparse(url)
|
|
102
|
+
if u.netloc and u.netloc.lower() != host and not tag.get("integrity"):
|
|
103
|
+
out.append(Candidate(
|
|
104
|
+
category="html_resource", subject=url, analyzer=self.name,
|
|
105
|
+
evidence=ev(ET.AST_PATTERN_MATCH, f"cross-origin ({u.netloc}) resource loaded without Subresource Integrity", tag) + test_penalty(),
|
|
106
|
+
attack_surface="supply_chain",
|
|
107
|
+
suggested_test="If the third party (or its CDN account/domain) is compromised or expires, this page runs attacker code. Check for takeover of the host and whether an SRI hash or self-hosting is feasible.",
|
|
108
|
+
metadata={"page": art.path, "origin_inferred_from": "document"}))
|
|
109
|
+
|
|
110
|
+
# ---- meta CSP
|
|
111
|
+
for m in doc.find_all("meta", attrs={"http-equiv": re.compile("content-security-policy", re.I)}):
|
|
112
|
+
pol = m.get("content", "")
|
|
113
|
+
weak = [t for t in ("'unsafe-inline'", "'unsafe-eval'") if t in pol] + (["wildcard source"] if re.search(r"(^|[\s;])\*([\s;]|$)", pol) else [])
|
|
114
|
+
if weak:
|
|
115
|
+
out.append(Candidate(
|
|
116
|
+
category="csp", subject=f"{page}: weak meta CSP ({', '.join(weak)})", analyzer=self.name,
|
|
117
|
+
evidence=ev(ET.AST_PATTERN_MATCH, "CSP <meta> allows " + ", ".join(weak), m)
|
|
118
|
+
+ [Evidence(ET.LITERAL_STRING_MATCH, "policy text contains the weak directive", self.name)] + test_penalty(),
|
|
119
|
+
attack_surface="csp_weak",
|
|
120
|
+
suggested_test="A script-src that allows inline/eval weakens XSS containment: look for an HTML/DOM injection sink to pair with it. Also compare against the real response header, which this offline file cannot show."))
|
|
121
|
+
|
|
122
|
+
# ---- comments that point at sensitive paths
|
|
123
|
+
for c in doc.find_all(string=lambda s: isinstance(s, Comment)):
|
|
124
|
+
for m in re.finditer(r"(?<![\w.])(/[a-zA-Z0-9_\-./]{3,})", str(c)):
|
|
125
|
+
path = m.group(1)
|
|
126
|
+
tag = classify(path)
|
|
127
|
+
if tag in ("admin", "internal", "debug"):
|
|
128
|
+
out.append(Candidate(
|
|
129
|
+
category="html_comment", subject=path, analyzer=self.name,
|
|
130
|
+
evidence=[Evidence(ET.LITERAL_STRING_MATCH, "path mentioned in an HTML comment", self.name, art.path, None, str(c).strip()[:120]),
|
|
131
|
+
Evidence(ET.NAMING_CONVENTION, f"path matches '{tag}' naming convention", self.name)] + test_penalty(),
|
|
132
|
+
attack_surface=tag,
|
|
133
|
+
suggested_test="Request the path directly; comments often reference routes the UI no longer links to."))
|
|
134
|
+
|
|
135
|
+
# ---- forms
|
|
136
|
+
for i, form in enumerate(doc.find_all("form"), 1):
|
|
137
|
+
method = (form.get("method") or "get").strip().lower()
|
|
138
|
+
action = (form.get("action") or "").strip()
|
|
139
|
+
inputs = form.find_all(["input", "textarea", "select"])
|
|
140
|
+
types = {(x.get("type") or "text").lower() for x in inputs}
|
|
141
|
+
names = [x.get("name") for x in inputs if x.get("name")]
|
|
142
|
+
has_pw, has_file = "password" in types, "file" in types or "multipart" in (form.get("enctype") or "")
|
|
143
|
+
has_csrf = any(_CSRF_NAME.search(n or "") for n in names) or has_csrf_meta
|
|
144
|
+
label = action or "(self)"
|
|
145
|
+
subject = f"{method.upper()} {label} [{page}]"
|
|
146
|
+
surface = "auth" if has_pw else "upload" if has_file else classify(action) if action else None
|
|
147
|
+
tests = []
|
|
148
|
+
if has_pw:
|
|
149
|
+
tests.append("Test credential handling: rate limiting/lockout, response differences for valid vs invalid users, password-reset and MFA bypass.")
|
|
150
|
+
if has_file:
|
|
151
|
+
tests.append("Test upload handling: extension/content-type/magic-byte checks, path traversal in filename, oversized files, stored-XSS via SVG/HTML.")
|
|
152
|
+
if method in _WRITE and not has_pw and not has_file:
|
|
153
|
+
tests.append("State-changing form: replay it from another origin/session to check CSRF protection and server-side authorization.")
|
|
154
|
+
fid = f"form:{art.path}#{i}"
|
|
155
|
+
g.add_node(fid, NodeKind.FORM, subject, file=art.path, method=method.upper(), fields=sorted(set(names)))
|
|
156
|
+
g.add_edge(pid, fid, EdgeKind.CONTAINS_FORM, self.name, "AST_PATTERN_MATCH", art.path, getattr(form, "sourceline", None))
|
|
157
|
+
|
|
158
|
+
fe = ev(ET.AST_PATTERN_MATCH, f"<form method={method.upper()}> with {len(inputs)} field(s)" + (" incl. password" if has_pw else "") + (" incl. file upload" if has_file else ""), form, _snippet(form)[:140])
|
|
159
|
+
out.append(Candidate(category="form", subject=subject, analyzer=self.name, evidence=fe + test_penalty(),
|
|
160
|
+
attack_surface=surface, suggested_test=" ".join(tests) or None,
|
|
161
|
+
metadata={"page": art.path, "method": method.upper(), "action": action, "fields": sorted(set(names)),
|
|
162
|
+
"has_password": has_pw, "has_file": has_file, "has_csrf_token": has_csrf}))
|
|
163
|
+
|
|
164
|
+
u = urlparse(action)
|
|
165
|
+
if has_pw and u.scheme == "http":
|
|
166
|
+
out.append(Candidate(
|
|
167
|
+
category="cleartext_credentials", subject=f"{action} [{page}]", analyzer=self.name,
|
|
168
|
+
evidence=fe[:1] + [Evidence(ET.LITERAL_STRING_MATCH, "password field submits to a literal http:// URL", self.name, art.path, getattr(form, "sourceline", None), action[:100])] + test_penalty(),
|
|
169
|
+
attack_surface="auth",
|
|
170
|
+
suggested_test="Credentials posted over cleartext HTTP are exposed to on-path attackers; confirm whether the origin redirects to HTTPS and whether HSTS is set."))
|
|
171
|
+
|
|
172
|
+
if method in _WRITE and not has_csrf:
|
|
173
|
+
out.append(Candidate(
|
|
174
|
+
category="form_csrf", subject=subject, analyzer=self.name,
|
|
175
|
+
evidence=fe[:1] + [Evidence(ET.LITERAL_STRING_MATCH, "no CSRF-style hidden field in the form and no csrf <meta> in the page", self.name)] + test_penalty(),
|
|
176
|
+
attack_surface="csrf_candidate",
|
|
177
|
+
suggested_test="Submit the request cross-site without a token. Cookies with SameSite=Lax/Strict or a custom-header requirement may already protect it: this is an absence-based hint only.",
|
|
178
|
+
metadata={"absence_based": True}))
|
|
179
|
+
|
|
180
|
+
# the form's action is an endpoint too: lets markup and JS corroborate each other
|
|
181
|
+
path = u.path if (u.netloc or action.startswith("/")) else None
|
|
182
|
+
if path and not u.query and PATH_RE.match(path) and (not u.netloc or u.scheme in ("http", "https")):
|
|
183
|
+
idx = ctx.shared.setdefault("js_endpoints", {}).setdefault(
|
|
184
|
+
path, {"methods": set(), "reliable_methods": set(), "fetch": False, "sites": []})
|
|
185
|
+
idx["methods"].add(method.upper())
|
|
186
|
+
idx["reliable_methods"].add(method.upper()) # a <form> states its method explicitly
|
|
187
|
+
idx["sites"].append((art.path, getattr(form, "sourceline", None)))
|
|
188
|
+
g.add_node(f"endpoint:{path}", NodeKind.ENDPOINT, path)
|
|
189
|
+
g.add_edge(fid, f"endpoint:{path}", EdgeKind.SUBMITS_TO, self.name, "AST_PATTERN_MATCH", art.path, getattr(form, "sourceline", None))
|
|
190
|
+
etag = classify(path)
|
|
191
|
+
out.append(Candidate(
|
|
192
|
+
category="endpoint", subject=path, analyzer=self.name,
|
|
193
|
+
evidence=fe[:1] + [Evidence(ET.LITERAL_STRING_MATCH, "literal form action", self.name)]
|
|
194
|
+
+ ([Evidence(ET.NAMING_CONVENTION, f"path matches '{etag}' naming convention", self.name)] if etag else []) + test_penalty(),
|
|
195
|
+
attack_surface=etag or surface, metadata={"method": method.upper(), "callee": "form"}))
|
|
196
|
+
return out
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""AnalyzerManager: topologically sorts analyzers by declared dependencies,
|
|
2
|
+
runs each project-wide, feeds proposed Candidates into the EvidenceEngine.
|
|
3
|
+
|
|
4
|
+
Phase 2 hardening -- the propose/promote boundary is now ENFORCED AT RUNTIME,
|
|
5
|
+
not just by convention (Python type hints enforce nothing):
|
|
6
|
+
* anything that is not a Candidate is rejected (analyzers cannot emit Findings)
|
|
7
|
+
* evidence is re-attributed to the analyzer that actually produced it, so a
|
|
8
|
+
plugin cannot spoof another analyzer's name to fake CROSS_ANALYZER_AGREEMENT
|
|
9
|
+
* CROSS_ANALYZER_AGREEMENT (engine-only) and RUNTIME_* (prober-only, see apply_runtime_evidence) evidence is dropped
|
|
10
|
+
* a per-candidate evidence cap blocks "confidence farming"
|
|
11
|
+
* one analyzer crashing is recorded as a diagnostic; the run continues
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import traceback
|
|
18
|
+
|
|
19
|
+
from jscope.analyzers.base import Analyzer, ProjectContext
|
|
20
|
+
from jscope.core.evidence import Candidate, EvidenceEngine, EvidenceType
|
|
21
|
+
|
|
22
|
+
MAX_EVIDENCE_PER_CANDIDATE = 60
|
|
23
|
+
_FORBIDDEN = {EvidenceType.CROSS_ANALYZER_AGREEMENT, EvidenceType.RUNTIME_CONTEXT, EvidenceType.RUNTIME_NOT_FOUND}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AnalyzerManager:
|
|
27
|
+
def __init__(self, analyzers: list[Analyzer]):
|
|
28
|
+
names = [a.name for a in analyzers]
|
|
29
|
+
dupes = {n for n in names if names.count(n) > 1}
|
|
30
|
+
if dupes:
|
|
31
|
+
raise ValueError(f"duplicate analyzer name(s): {', '.join(sorted(dupes))}")
|
|
32
|
+
self.analyzers = {a.name: a for a in analyzers}
|
|
33
|
+
self.diagnostics: list[str] = []
|
|
34
|
+
|
|
35
|
+
def _topo_sort(self) -> list[Analyzer]:
|
|
36
|
+
visited: set[str] = set()
|
|
37
|
+
temp: set[str] = set()
|
|
38
|
+
order: list[Analyzer] = []
|
|
39
|
+
|
|
40
|
+
def visit(name: str):
|
|
41
|
+
if name in visited:
|
|
42
|
+
return
|
|
43
|
+
if name in temp:
|
|
44
|
+
raise ValueError(f"circular analyzer dependency detected at '{name}'")
|
|
45
|
+
temp.add(name)
|
|
46
|
+
for dep in self.analyzers[name].depends_on:
|
|
47
|
+
if dep not in self.analyzers:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"analyzer '{name}' depends on '{dep}', which is not registered/enabled"
|
|
50
|
+
)
|
|
51
|
+
visit(dep)
|
|
52
|
+
for dep in getattr(self.analyzers[name], "run_after", []):
|
|
53
|
+
if dep in self.analyzers:
|
|
54
|
+
visit(dep)
|
|
55
|
+
temp.discard(name)
|
|
56
|
+
visited.add(name)
|
|
57
|
+
order.append(self.analyzers[name])
|
|
58
|
+
|
|
59
|
+
for name in self.analyzers:
|
|
60
|
+
visit(name)
|
|
61
|
+
return order
|
|
62
|
+
|
|
63
|
+
def _sanitize(self, analyzer: Analyzer, produced: object) -> list[Candidate]:
|
|
64
|
+
diag = self.diagnostics
|
|
65
|
+
if not isinstance(produced, list):
|
|
66
|
+
diag.append(f"[{analyzer.name}] returned {type(produced).__name__}, expected list[Candidate]; ignored")
|
|
67
|
+
return []
|
|
68
|
+
clean: list[Candidate] = []
|
|
69
|
+
for c in produced:
|
|
70
|
+
if not isinstance(c, Candidate):
|
|
71
|
+
diag.append(f"[{analyzer.name}] emitted a {type(c).__name__}; only Candidates are accepted "
|
|
72
|
+
"(analyzers cannot create Findings) -- dropped")
|
|
73
|
+
continue
|
|
74
|
+
c.analyzer = analyzer.name
|
|
75
|
+
kept = []
|
|
76
|
+
for e in c.evidence:
|
|
77
|
+
if e.type in _FORBIDDEN:
|
|
78
|
+
diag.append(f"[{analyzer.name}] tried to emit {e.type.value} on '{c.subject}'; "
|
|
79
|
+
"this evidence type is engine-controlled -- dropped")
|
|
80
|
+
continue
|
|
81
|
+
if e.analyzer != analyzer.name:
|
|
82
|
+
diag.append(f"[{analyzer.name}] evidence on '{c.subject}' claimed analyzer '{e.analyzer}'; "
|
|
83
|
+
"re-attributed")
|
|
84
|
+
e = dataclasses.replace(e, analyzer=analyzer.name)
|
|
85
|
+
kept.append(e)
|
|
86
|
+
if len(kept) > MAX_EVIDENCE_PER_CANDIDATE:
|
|
87
|
+
diag.append(f"[{analyzer.name}] '{c.subject}' had {len(kept)} evidence items; "
|
|
88
|
+
f"truncated to {MAX_EVIDENCE_PER_CANDIDATE}")
|
|
89
|
+
kept = kept[:MAX_EVIDENCE_PER_CANDIDATE]
|
|
90
|
+
c.evidence = kept
|
|
91
|
+
if kept:
|
|
92
|
+
clean.append(c)
|
|
93
|
+
return clean
|
|
94
|
+
|
|
95
|
+
def run(self, ctx: ProjectContext, engine: EvidenceEngine, enabled: set[str] | None = None) -> dict[str, int]:
|
|
96
|
+
"""Runs all (or `enabled`-filtered) analyzers in dependency order,
|
|
97
|
+
proposing every candidate to `engine`. Returns per-analyzer candidate counts."""
|
|
98
|
+
counts: dict[str, int] = {}
|
|
99
|
+
for analyzer in self._topo_sort():
|
|
100
|
+
if enabled is not None and analyzer.name not in enabled:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
candidates = self._sanitize(analyzer, analyzer.analyze_project(ctx))
|
|
104
|
+
except Exception as exc: # isolate analyzer failures
|
|
105
|
+
self.diagnostics.append(
|
|
106
|
+
f"[{analyzer.name}] crashed: {type(exc).__name__}: {exc} "
|
|
107
|
+
f"({traceback.extract_tb(exc.__traceback__)[-1].name})")
|
|
108
|
+
counts[analyzer.name] = 0
|
|
109
|
+
continue
|
|
110
|
+
counts[analyzer.name] = len(candidates)
|
|
111
|
+
for c in candidates:
|
|
112
|
+
engine.propose(c)
|
|
113
|
+
ctx.diagnostics.extend(self.diagnostics)
|
|
114
|
+
return counts
|