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,385 @@
1
+ """Higher-level AST queries shared by the Phase 2 analyzers.
2
+
3
+ Sits on top of ASTEngine so analyzers never touch raw tree-sitter grammar
4
+ quirks. Results are memoized per ParsedFile (`pf.memo`) because several
5
+ analyzers ask the same questions of the same file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import posixpath
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ from jscope.parsing.ast_engine import ASTEngine, CallSite, ParsedFile, ResolvedValue
16
+
17
+ _PUNCT = {"(", ")", ",", "[", "]", "{", "}"}
18
+ _FUNC_TYPES = {"function_declaration", "function_expression", "function", "arrow_function",
19
+ "generator_function_declaration", "method_definition"}
20
+
21
+
22
+ def norm_path(p: str) -> str:
23
+ return posixpath.normpath(p.replace("\\", "/"))
24
+
25
+
26
+ def bindings_for(engine: ASTEngine, pf: ParsedFile) -> dict[str, Any]:
27
+ if "bindings" not in pf.memo:
28
+ pf.memo["bindings"] = engine._local_bindings(pf, pf.tree.root_node) if pf.tree else {}
29
+ return pf.memo["bindings"]
30
+
31
+
32
+ def _callee_node_name(engine: ASTEngine, pf: ParsedFile, fn) -> str | None:
33
+ # minified JSX runtime calls look like (0,r.jsx)(...): unwrap to the last member
34
+ if fn.type == "parenthesized_expression":
35
+ inner = [c for c in fn.children if c.type not in ("(", ")")]
36
+ if inner and inner[0].type == "sequence_expression":
37
+ inner = [c for c in inner[0].children if c.type != ","]
38
+ return _callee_node_name(engine, pf, inner[-1]) if inner else None
39
+ return engine._callee_dotted_name(pf, fn)
40
+
41
+
42
+ def iter_calls(engine: ASTEngine, pf: ParsedFile) -> list[CallSite]:
43
+ """EVERY call_expression and new_expression, with dotted callee when resolvable."""
44
+ if "calls" in pf.memo:
45
+ return pf.memo["calls"]
46
+ sites: list[CallSite] = []
47
+ if pf.tree is not None:
48
+ bindings = bindings_for(engine, pf)
49
+ for node in engine.walk(pf.tree.root_node):
50
+ if node.type == "call_expression":
51
+ fn = node.child_by_field_name("function")
52
+ elif node.type == "new_expression":
53
+ fn = node.child_by_field_name("constructor")
54
+ else:
55
+ continue
56
+ if fn is None:
57
+ continue
58
+ name = _callee_node_name(engine, pf, fn)
59
+ if name is None:
60
+ continue
61
+ args = call_args(node)
62
+ sites.append(CallSite(
63
+ callee=name, args_raw=[pf.node_text(a) for a in args],
64
+ args_resolved=[engine.resolve_value(pf, a, bindings) for a in args],
65
+ line=pf.line_of(node), node=node))
66
+ pf.memo["calls"] = sites
67
+ return sites
68
+
69
+
70
+ def call_args(call_node) -> list[Any]:
71
+ args = call_node.child_by_field_name("arguments")
72
+ if args is None:
73
+ return []
74
+ if args.type == "template_string": # gql`...` tagged template
75
+ return [args]
76
+ return [c for c in args.children if c.type not in _PUNCT]
77
+
78
+
79
+ def last_segment(dotted: str) -> str:
80
+ return dotted.rsplit(".", 1)[-1]
81
+
82
+
83
+ def key_name(pf: ParsedFile, key_node) -> str | None:
84
+ if key_node is None:
85
+ return None
86
+ if key_node.type in ("property_identifier", "identifier", "shorthand_property_identifier"):
87
+ return pf.node_text(key_node)
88
+ if key_node.type == "string":
89
+ return pf.node_text(key_node).strip("'\"")
90
+ return None
91
+
92
+
93
+ def object_props(pf: ParsedFile, obj) -> dict[str, Any]:
94
+ """name -> value node for a JS object literal (pairs and shorthand)."""
95
+ out: dict[str, Any] = {}
96
+ if obj is None or obj.type != "object":
97
+ return out
98
+ for ch in obj.children:
99
+ if ch.type == "pair":
100
+ k = key_name(pf, ch.child_by_field_name("key"))
101
+ v = ch.child_by_field_name("value")
102
+ if k is not None and v is not None:
103
+ out[k] = v
104
+ elif ch.type == "shorthand_property_identifier":
105
+ out[pf.node_text(ch)] = ch
106
+ return out
107
+
108
+
109
+ def array_items(node) -> list[Any]:
110
+ if node is None or node.type != "array":
111
+ return []
112
+ return [c for c in node.children if c.type not in _PUNCT]
113
+
114
+
115
+ def string_list(engine: ASTEngine, pf: ParsedFile, node) -> list[str]:
116
+ """['a','b'] or 'a' -> list of resolved strings (unresolvable items skipped)."""
117
+ if node is None:
118
+ return []
119
+ b = bindings_for(engine, pf)
120
+ items = array_items(node) if node.type == "array" else [node]
121
+ out = []
122
+ for it in items:
123
+ rv = engine.resolve_value(pf, it, b)
124
+ if rv.value is not None and not rv.is_placeholder:
125
+ out.append(rv.value)
126
+ return out
127
+
128
+
129
+ def enclosing_function_name(pf: ParsedFile, node) -> str | None:
130
+ cur = node.parent
131
+ while cur is not None:
132
+ if cur.type in ("function_declaration", "class_declaration", "generator_function_declaration"):
133
+ n = cur.child_by_field_name("name")
134
+ if n is not None:
135
+ return pf.node_text(n)
136
+ if cur.type in ("arrow_function", "function_expression", "function"):
137
+ par = cur.parent
138
+ # unwrap HOCs like memo(forwardRef(() => ...))
139
+ while par is not None and par.type in ("arguments", "call_expression", "parenthesized_expression"):
140
+ par = par.parent
141
+ if par is not None and par.type == "variable_declarator":
142
+ n = par.child_by_field_name("name")
143
+ if n is not None and n.type == "identifier":
144
+ return pf.node_text(n)
145
+ cur = cur.parent
146
+ return None
147
+
148
+
149
+ def function_index(engine: ASTEngine, pf: ParsedFile) -> dict[str, Any]:
150
+ """name -> node whose byte range contains the function/component body."""
151
+ if "funcs" in pf.memo:
152
+ return pf.memo["funcs"]
153
+ idx: dict[str, Any] = {}
154
+ if pf.tree is not None:
155
+ for node in engine.walk(pf.tree.root_node):
156
+ if node.type in ("function_declaration", "class_declaration", "generator_function_declaration"):
157
+ n = node.child_by_field_name("name")
158
+ if n is not None:
159
+ idx[pf.node_text(n)] = node
160
+ elif node.type == "variable_declarator":
161
+ n = node.child_by_field_name("name")
162
+ v = node.child_by_field_name("value")
163
+ if n is None or v is None or n.type != "identifier":
164
+ continue
165
+ if v.type in _FUNC_TYPES or (v.type == "call_expression" and any(
166
+ d.type in _FUNC_TYPES for d in engine.walk(v))):
167
+ idx[pf.node_text(n)] = v
168
+ pf.memo["funcs"] = idx
169
+ return idx
170
+
171
+
172
+ @dataclass
173
+ class ImportRef:
174
+ source: str
175
+ kind: str # "import" | "require" | "lazy_import"
176
+ imported: str | None = None
177
+ line: int = 0
178
+
179
+
180
+ def _str_of(pf: ParsedFile, node) -> str | None:
181
+ return pf.node_text(node).strip("'\"`") if node is not None and node.type == "string" else None
182
+
183
+
184
+ def _dynamic_import_source(pf: ParsedFile, node) -> str | None:
185
+ for d in _walk(node):
186
+ if d.type == "call_expression":
187
+ fn = d.child_by_field_name("function")
188
+ if fn is not None and fn.type == "import":
189
+ a = call_args(d)
190
+ return _str_of(pf, a[0]) if a else None
191
+ return None
192
+
193
+
194
+ def _walk(node):
195
+ yield node
196
+ for c in node.children:
197
+ yield from _walk(c)
198
+
199
+
200
+ def imports(engine: ASTEngine, pf: ParsedFile) -> tuple[dict[str, ImportRef], list[ImportRef]]:
201
+ """(local_name -> ImportRef, every module edge incl. side-effect/dynamic imports)."""
202
+ if "imports" in pf.memo:
203
+ return pf.memo["imports"]
204
+ by_name: dict[str, ImportRef] = {}
205
+ edges: list[ImportRef] = []
206
+ if pf.tree is not None:
207
+ for node in engine.walk(pf.tree.root_node):
208
+ if node.type == "import_statement":
209
+ src = next((_str_of(pf, c) for c in node.children if c.type == "string"), None)
210
+ if src is None:
211
+ continue
212
+ edges.append(ImportRef(src, "import", None, pf.line_of(node)))
213
+ clause = next((c for c in node.children if c.type == "import_clause"), None)
214
+ if clause is None:
215
+ continue
216
+ for c in clause.children:
217
+ if c.type == "identifier":
218
+ by_name[pf.node_text(c)] = ImportRef(src, "import", "default", pf.line_of(node))
219
+ elif c.type == "named_imports":
220
+ for spec in c.children:
221
+ if spec.type == "import_specifier":
222
+ ids = [x for x in spec.children if x.type == "identifier"]
223
+ if ids:
224
+ local = pf.node_text(ids[-1])
225
+ by_name[local] = ImportRef(src, "import", pf.node_text(ids[0]), pf.line_of(node))
226
+ elif c.type == "namespace_import":
227
+ ids = [x for x in c.children if x.type == "identifier"]
228
+ if ids:
229
+ by_name[pf.node_text(ids[-1])] = ImportRef(src, "import", "*", pf.line_of(node))
230
+ elif node.type == "variable_declarator":
231
+ n = node.child_by_field_name("name")
232
+ v = node.child_by_field_name("value")
233
+ if n is None or v is None or n.type != "identifier":
234
+ continue
235
+ if v.type == "call_expression":
236
+ fn = v.child_by_field_name("function")
237
+ if fn is not None and fn.type == "identifier" and pf.node_text(fn) == "require":
238
+ a = call_args(v)
239
+ src = _str_of(pf, a[0]) if a else None
240
+ if src:
241
+ by_name[pf.node_text(n)] = ImportRef(src, "require", None, pf.line_of(node))
242
+ edges.append(ImportRef(src, "require", None, pf.line_of(node)))
243
+ continue
244
+ src = _dynamic_import_source(pf, v) # lazy(() => import('./X')), dynamic(...), loadable(...)
245
+ if src:
246
+ by_name[pf.node_text(n)] = ImportRef(src, "lazy_import", None, pf.line_of(node))
247
+ elif node.type == "call_expression":
248
+ fn = node.child_by_field_name("function")
249
+ if fn is not None and fn.type == "import":
250
+ a = call_args(node)
251
+ src = _str_of(pf, a[0]) if a else None
252
+ if src:
253
+ edges.append(ImportRef(src, "dynamic_import", None, pf.line_of(node)))
254
+ pf.memo["imports"] = (by_name, edges)
255
+ return pf.memo["imports"]
256
+
257
+
258
+ class FileIndex:
259
+ """Resolve relative module specifiers to project files. Alias paths
260
+ ('@/x', bare package names) are intentionally NOT resolved -- Phase 2
261
+ does not read tsconfig/webpack alias config."""
262
+
263
+ def __init__(self, files: list[ParsedFile]):
264
+ self.by_path = {norm_path(f.path): f for f in files if not f.skipped_reason}
265
+
266
+ def resolve(self, from_path: str, source: str) -> ParsedFile | None:
267
+ if not source.startswith("."):
268
+ return None
269
+ base = posixpath.normpath(posixpath.join(posixpath.dirname(norm_path(from_path)), source))
270
+ for cand in (base, base + ".js", base + ".jsx", base + ".mjs", base + ".cjs",
271
+ base + "/index.js", base + "/index.jsx"):
272
+ if cand in self.by_path:
273
+ return self.by_path[cand]
274
+ return None
275
+
276
+ def by_basename(self, name: str) -> ParsedFile | None:
277
+ name = name.split("?")[0].lstrip("/")
278
+ for p, f in self.by_path.items():
279
+ if p == name or p.endswith("/" + name):
280
+ return f
281
+ return None
282
+
283
+
284
+ def calls_within(sites: list[CallSite], container) -> list[CallSite]:
285
+ return [s for s in sites if container.start_byte <= s.node.start_byte and s.node.end_byte <= container.end_byte]
286
+
287
+
288
+ # ---- JSX / element helpers -------------------------------------------------
289
+
290
+ @dataclass
291
+ class ElementUse:
292
+ name: str
293
+ props: dict[str, str] # prop name -> raw value text (quotes/braces stripped)
294
+
295
+
296
+ def _jsx_name(pf: ParsedFile, el) -> str | None:
297
+ for c in el.children:
298
+ if c.type in ("identifier", "member_expression", "nested_identifier", "jsx_namespace_name"):
299
+ return pf.node_text(c)
300
+ return None
301
+
302
+
303
+ def jsx_attrs(pf: ParsedFile, el) -> dict[str, tuple[str, Any]]:
304
+ """attr name -> (raw text, value node)."""
305
+ out = {}
306
+ for c in el.children:
307
+ if c.type != "jsx_attribute":
308
+ continue
309
+ kids = [k for k in c.children if k.type != "="]
310
+ if not kids:
311
+ continue
312
+ name = pf.node_text(kids[0])
313
+ val = kids[1] if len(kids) > 1 else None
314
+ if val is not None and val.type == "jsx_expression":
315
+ inner = [k for k in val.children if k.type not in ("{", "}")]
316
+ val = inner[0] if inner else None
317
+ raw = pf.node_text(val).strip("'\"") if val is not None else "true"
318
+ out[name] = (raw, val)
319
+ return out
320
+
321
+
322
+ def element_chain(engine: ASTEngine, pf: ParsedFile, node, _depth: int = 0) -> list[ElementUse]:
323
+ """Component names from outermost wrapper to innermost for an element
324
+ expression: <Guard role="x"><Page/></Guard>, jsx(Guard,{children:jsx(Page,{})}),
325
+ createElement(Guard, props, createElement(Page))."""
326
+ if node is None or _depth > 6:
327
+ return []
328
+ t = node.type
329
+ if t == "jsx_expression":
330
+ inner = [k for k in node.children if k.type not in ("{", "}")]
331
+ return element_chain(engine, pf, inner[0], _depth + 1) if inner else []
332
+ if t == "jsx_self_closing_element":
333
+ n = _jsx_name(pf, node)
334
+ return [ElementUse(n, {k: v[0] for k, v in jsx_attrs(pf, node).items()})] if n else []
335
+ if t == "jsx_element":
336
+ opening = next((c for c in node.children if c.type == "jsx_opening_element"), None)
337
+ if opening is None:
338
+ return []
339
+ n = _jsx_name(pf, opening)
340
+ if not n:
341
+ return []
342
+ me = [ElementUse(n, {k: v[0] for k, v in jsx_attrs(pf, opening).items()})]
343
+ for c in node.children:
344
+ if c.type in ("jsx_element", "jsx_self_closing_element", "jsx_expression"):
345
+ sub = element_chain(engine, pf, c, _depth + 1)
346
+ if sub:
347
+ return me + sub
348
+ return me
349
+ if t == "call_expression":
350
+ fn = node.child_by_field_name("function")
351
+ name = _callee_node_name(engine, pf, fn) if fn is not None else None
352
+ if name and last_segment(name) in ("jsx", "jsxs", "jsxDEV", "createElement", "h"):
353
+ args = call_args(node)
354
+ if not args:
355
+ return []
356
+ first = pf.node_text(args[0]).strip("'\"")
357
+ if args[0].type not in ("identifier", "member_expression"):
358
+ return []
359
+ props_node = args[1] if len(args) > 1 and args[1].type == "object" else None
360
+ props = {k: pf.node_text(v).strip("'\"") for k, v in object_props(pf, props_node).items()}
361
+ me = [ElementUse(first, props)]
362
+ child = object_props(pf, props_node).get("children") if props_node else None
363
+ if child is None and len(args) > 2:
364
+ child = args[2]
365
+ if child is not None:
366
+ if child.type == "array":
367
+ for it in array_items(child):
368
+ sub = element_chain(engine, pf, it, _depth + 1)
369
+ if sub:
370
+ return me + sub
371
+ else:
372
+ sub = element_chain(engine, pf, child, _depth + 1)
373
+ if sub:
374
+ return me + sub
375
+ return me
376
+ if t == "identifier": # component: Admin / Component: Admin
377
+ return [ElementUse(pf.node_text(node), {})]
378
+ if t in ("arrow_function", "function_expression"): # () => import('./x') or () => <X/>
379
+ body = node.child_by_field_name("body")
380
+ return element_chain(engine, pf, body, _depth + 1) if body is not None else []
381
+ return []
382
+
383
+
384
+ def quoted_strings(raw: str) -> list[str]:
385
+ return re.findall(r"['\"]([^'\"]+)['\"]", raw)
jscope/parsing/wasm.py ADDED
@@ -0,0 +1,146 @@
1
+ """Tiny WebAssembly binary reader (pure Python, no dependencies).
2
+
3
+ Extracts what recon needs, nothing more: section layout, imports, exports, memory limits, the `producers` and `name`
4
+ custom sections, and the byte range of the data section (so string hits can be tied to real data segments).
5
+ It stops at the first malformed construct and returns what it has -- never raises on hostile input.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+ _KINDS = {0: "func", 1: "table", 2: "memory", 3: "global", 4: "tag"}
14
+ _PRINTABLE = re.compile(rb"[\x20-\x7e]{8,}")
15
+ MAX_SECTIONS = 200
16
+ MAX_ITEMS = 5000
17
+
18
+
19
+ class _Bad(Exception):
20
+ pass
21
+
22
+
23
+ def _leb(data: bytes, i: int) -> tuple[int, int]:
24
+ result = shift = 0
25
+ while True:
26
+ if i >= len(data) or shift > 63:
27
+ raise _Bad
28
+ b = data[i]
29
+ i += 1
30
+ result |= (b & 0x7F) << shift
31
+ if not b & 0x80:
32
+ return result, i
33
+ shift += 7
34
+
35
+
36
+ def _name(data: bytes, i: int) -> tuple[str, int]:
37
+ n, i = _leb(data, i)
38
+ if i + n > len(data):
39
+ raise _Bad
40
+ return data[i:i + n].decode("utf-8", errors="replace"), i + n
41
+
42
+
43
+ def _limits(data: bytes, i: int) -> tuple[dict, int]:
44
+ flag, i = _leb(data, i)
45
+ lo, i = _leb(data, i)
46
+ hi = None
47
+ if flag & 1:
48
+ hi, i = _leb(data, i)
49
+ return {"min": lo, "max": hi, "shared": bool(flag & 2), "memory64": bool(flag & 4)}, i
50
+
51
+
52
+ def is_wasm(data: bytes) -> bool:
53
+ return data[:4] == b"\x00asm"
54
+
55
+
56
+ def parse_wasm(data: bytes) -> dict[str, Any]:
57
+ info: dict[str, Any] = {"version": None, "sections": [], "imports": [], "exports": [], "memories": [], "customs": [],
58
+ "producers": {}, "has_name_section": False, "data_range": None, "truncated": False}
59
+ if not is_wasm(data) or len(data) < 8:
60
+ info["truncated"] = True
61
+ return info
62
+ info["version"] = int.from_bytes(data[4:8], "little")
63
+ i = 8
64
+ try:
65
+ while i < len(data) and len(info["sections"]) < MAX_SECTIONS:
66
+ sid = data[i]
67
+ size, j = _leb(data, i + 1)
68
+ end = j + size
69
+ if end > len(data):
70
+ raise _Bad
71
+ info["sections"].append({"id": sid, "size": size, "offset": j})
72
+ if sid == 0:
73
+ cname, k = _name(data, j)
74
+ info["customs"].append(cname)
75
+ if cname == "name":
76
+ info["has_name_section"] = True
77
+ elif cname == "producers":
78
+ _producers(data, k, end, info)
79
+ elif sid == 2:
80
+ _imports(data, j, end, info)
81
+ elif sid == 5:
82
+ cnt, k = _leb(data, j)
83
+ for _ in range(min(cnt, 16)):
84
+ lim, k = _limits(data, k)
85
+ info["memories"].append(lim)
86
+ elif sid == 7:
87
+ cnt, k = _leb(data, j)
88
+ for _ in range(min(cnt, MAX_ITEMS)):
89
+ nm, k = _name(data, k)
90
+ kind, k = data[k], k + 1
91
+ _, k = _leb(data, k)
92
+ info["exports"].append({"name": nm, "kind": _KINDS.get(kind, str(kind))})
93
+ elif sid == 11:
94
+ info["data_range"] = (j, end)
95
+ i = end
96
+ except (_Bad, IndexError):
97
+ info["truncated"] = True
98
+ return info
99
+
100
+
101
+ def _producers(data: bytes, k: int, end: int, info: dict) -> None:
102
+ try:
103
+ nfields, k = _leb(data, k)
104
+ for _ in range(min(nfields, 8)):
105
+ field, k = _name(data, k)
106
+ nvals, k = _leb(data, k)
107
+ vals = []
108
+ for _ in range(min(nvals, 16)):
109
+ nm, k = _name(data, k)
110
+ ver, k = _name(data, k)
111
+ vals.append(f"{nm} {ver}".strip())
112
+ info["producers"][field] = vals
113
+ except (_Bad, IndexError):
114
+ pass
115
+
116
+
117
+ def _imports(data: bytes, j: int, end: int, info: dict) -> None:
118
+ cnt, k = _leb(data, j)
119
+ for _ in range(min(cnt, MAX_ITEMS)):
120
+ mod, k = _name(data, k)
121
+ nm, k = _name(data, k)
122
+ kind, k = data[k], k + 1
123
+ if kind == 0:
124
+ _, k = _leb(data, k)
125
+ elif kind == 1:
126
+ k += 1
127
+ _, k = _limits(data, k)
128
+ elif kind == 2:
129
+ _, k = _limits(data, k)
130
+ elif kind == 3:
131
+ k += 2
132
+ elif kind == 4:
133
+ k += 1
134
+ _, k = _leb(data, k)
135
+ else:
136
+ raise _Bad
137
+ info["imports"].append({"module": mod, "name": nm, "kind": _KINDS.get(kind, str(kind))})
138
+
139
+
140
+ def printable_strings(data: bytes, limit: int = 20000) -> list[tuple[int, str]]:
141
+ out = []
142
+ for m in _PRINTABLE.finditer(data):
143
+ out.append((m.start(), m.group().decode("ascii")))
144
+ if len(out) >= limit:
145
+ break
146
+ return out
@@ -0,0 +1,63 @@
1
+ """Passive candidate-host inventory and assessment-specific approvals; no network probing."""
2
+ from __future__ import annotations
3
+ import json, time, hashlib
4
+ from pathlib import Path
5
+ from jscope.workspace.assessment import normalize_domain
6
+ from jscope.workspace.common import WorkspaceError, _atomic_write
7
+
8
+ def discover_candidates(root_domain: str, host_files=(), existing=()):
9
+ root=normalize_domain(root_domain); candidates={}
10
+ def add(value,source):
11
+ try:
12
+ host=normalize_domain(value)
13
+ if host != root and not host.endswith('.'+root):
14
+ candidates.setdefault(str(value), {'hostname':host,'state':'excluded','reason':'outside-root-domain','provenance':source,'discovered_at':time.time()})
15
+ else:
16
+ candidates.setdefault(host, {'hostname':host,'state':'known','reason':None,'provenance':source,'discovered_at':time.time()})
17
+ except WorkspaceError as e:
18
+ candidates.setdefault(str(value), {'hostname':None,'state':'excluded','reason':'invalid-hostname','detail':str(e),'provenance':source,'discovered_at':time.time()})
19
+ for h in existing: add(h,'workspace-knowledge')
20
+ for filename in host_files:
21
+ p=Path(filename)
22
+ for line in p.read_text(encoding='utf-8').splitlines():
23
+ val=line.strip()
24
+ if val and not val.startswith('#'): add(val, f'host-file:{p.name}')
25
+ add(root,'root-domain')
26
+ return list(candidates.values())
27
+
28
+ def persist_candidates(manifest_path, candidates):
29
+ p=Path(manifest_path)
30
+ try: data=json.loads(p.read_text(encoding='utf-8'))
31
+ except (OSError,ValueError) as e: raise WorkspaceError('assessment manifest missing or corrupt; refusing discovery update') from e
32
+ if data.get('schema')!='jscope-assessment/1' or not data.get('assessment_id') or not data.get('root_domain'):
33
+ raise WorkspaceError('invalid assessment manifest')
34
+ hosts=data.setdefault('hosts',{})
35
+ for c in candidates:
36
+ h=c.get('hostname')
37
+ if not h or c.get('state') != 'known': continue
38
+ prior=hosts.get(h)
39
+ if prior:
40
+ prior.setdefault('provenance',[])
41
+ if c['provenance'] not in prior['provenance']: prior['provenance'].append(c['provenance'])
42
+ else:
43
+ hosts[h]={'state':'known','discovery_source':c['provenance'],'discovered_at':c['discovered_at']}
44
+ data.setdefault('discovery',[]).append({'at':time.time(),'candidate_count':len(candidates),'candidates':candidates})
45
+ _atomic_write(p,data); return data
46
+
47
+ def approve_hosts(manifest_path, hostnames):
48
+ p=Path(manifest_path)
49
+ try: data=json.loads(p.read_text(encoding='utf-8'))
50
+ except (OSError,ValueError) as e: raise WorkspaceError('assessment manifest missing or corrupt; approval denied') from e
51
+ if data.get('schema')!='jscope-assessment/1' or not data.get('assessment_id'): raise WorkspaceError('invalid assessment manifest; approval denied')
52
+ root=normalize_domain(data['root_domain']); normalized=[]
53
+ for raw in hostnames:
54
+ h=normalize_domain(raw)
55
+ if h != root and not h.endswith('.'+root): raise WorkspaceError(f'approval denied: {h} is outside root domain')
56
+ if h not in data.get('hosts',{}): raise WorkspaceError(f'approval denied: {h} is not a known candidate')
57
+ normalized.append(h)
58
+ if not normalized: raise WorkspaceError('no hosts specified for approval')
59
+ now=time.time(); decision={'at':now,'assessment_id':data['assessment_id'],'hosts':sorted(set(normalized)),'decision':'approved'}
60
+ data.setdefault('approval_history',[]).append(decision)
61
+ auth=data.setdefault('authorization',{}); auth['approved_hosts']=sorted(set(auth.get('approved_hosts',[]))|set(normalized)); auth['approved_at']=now; auth['fresh_for_this_run']=True
62
+ for h in normalized: data['hosts'][h]['state']='approved'
63
+ data['status']='approved'; _atomic_write(p,data); return decision