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.
Files changed (81) hide show
  1. jscope/__init__.py +1 -0
  2. jscope/__main__.py +10 -0
  3. jscope/ai/__init__.py +0 -0
  4. jscope/ai/explain.py +180 -0
  5. jscope/ai/providers.py +71 -0
  6. jscope/analyzers/__init__.py +0 -0
  7. jscope/analyzers/auth_analyzer.py +170 -0
  8. jscope/analyzers/authorization_analyzer.py +196 -0
  9. jscope/analyzers/base.py +56 -0
  10. jscope/analyzers/common.py +78 -0
  11. jscope/analyzers/dataflow_analyzer.py +192 -0
  12. jscope/analyzers/endpoint_analyzer.py +150 -0
  13. jscope/analyzers/framework_detector.py +82 -0
  14. jscope/analyzers/graphql_analyzer.py +198 -0
  15. jscope/analyzers/graphql_schema_analyzer.py +132 -0
  16. jscope/analyzers/html_analyzer.py +196 -0
  17. jscope/analyzers/manager.py +114 -0
  18. jscope/analyzers/openapi_analyzer.py +278 -0
  19. jscope/analyzers/router_analyzer.py +502 -0
  20. jscope/analyzers/secret_analyzer.py +99 -0
  21. jscope/analyzers/service_worker_analyzer.py +142 -0
  22. jscope/analyzers/source_map_analyzer.py +119 -0
  23. jscope/analyzers/storage_analyzer.py +125 -0
  24. jscope/analyzers/wasm_analyzer.py +139 -0
  25. jscope/assessment_engine.py +285 -0
  26. jscope/assessment_reporting.py +71 -0
  27. jscope/benchmark/__init__.py +1 -0
  28. jscope/benchmark/fixtures.py +105 -0
  29. jscope/benchmark/runner.py +155 -0
  30. jscope/cli.py +1241 -0
  31. jscope/config.py +72 -0
  32. jscope/core/__init__.py +0 -0
  33. jscope/core/artifacts.py +139 -0
  34. jscope/core/cache.py +198 -0
  35. jscope/core/classification.py +56 -0
  36. jscope/core/evidence.py +411 -0
  37. jscope/core/graph.py +196 -0
  38. jscope/core/redact.py +67 -0
  39. jscope/crawler/__init__.py +0 -0
  40. jscope/crawler/crawler.py +176 -0
  41. jscope/dataflow/__init__.py +1 -0
  42. jscope/dataflow/engine.py +1684 -0
  43. jscope/dataflow/modules.py +205 -0
  44. jscope/dataflow/rules.py +118 -0
  45. jscope/downloader/__init__.py +0 -0
  46. jscope/downloader/downloader.py +116 -0
  47. jscope/parsing/__init__.py +0 -0
  48. jscope/parsing/ast_engine.py +322 -0
  49. jscope/parsing/graphql_schema.py +153 -0
  50. jscope/parsing/html.py +55 -0
  51. jscope/parsing/queries.py +385 -0
  52. jscope/parsing/wasm.py +146 -0
  53. jscope/passive_discovery.py +63 -0
  54. jscope/pipeline.py +196 -0
  55. jscope/plugin/__init__.py +0 -0
  56. jscope/plugin/_worker.py +199 -0
  57. jscope/plugin/loader.py +225 -0
  58. jscope/plugin/sandbox.py +207 -0
  59. jscope/reporting/__init__.py +0 -0
  60. jscope/reporting/formats.py +208 -0
  61. jscope/reporting/human.py +137 -0
  62. jscope/reporting/json_report.py +35 -0
  63. jscope/reporting/priority.py +116 -0
  64. jscope/runtime/__init__.py +0 -0
  65. jscope/runtime/observe.py +397 -0
  66. jscope/runtime/prober.py +259 -0
  67. jscope/runtime/scope.py +288 -0
  68. jscope/security_transport.py +246 -0
  69. jscope/verify/__init__.py +1 -0
  70. jscope/verify/engine.py +290 -0
  71. jscope/workspace/__init__.py +0 -0
  72. jscope/workspace/assessment.py +133 -0
  73. jscope/workspace/common.py +25 -0
  74. jscope/workspace/diff.py +143 -0
  75. jscope/workspace/store.py +206 -0
  76. jscope-0.4.2.dist-info/METADATA +180 -0
  77. jscope-0.4.2.dist-info/RECORD +81 -0
  78. jscope-0.4.2.dist-info/WHEEL +5 -0
  79. jscope-0.4.2.dist-info/entry_points.txt +2 -0
  80. jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
  81. 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