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,411 @@
1
+ """
2
+ Evidence Engine
3
+ ===============
4
+
5
+ This is the architectural spine of JScope, per the design philosophy:
6
+
7
+ "Detectors propose, the engine decides."
8
+
9
+ Analyzers NEVER get to emit a "finding" directly. They emit `Candidate`
10
+ objects backed by typed `Evidence`. Only the `EvidenceEngine` can promote a
11
+ candidate into a user-visible `Finding`, and only after running it through a
12
+ deterministic, fully-explainable confidence function.
13
+
14
+ This is deliberate: it's the one architectural boundary that prevents a
15
+ noisy (or badly-written, e.g. community-contributed) analyzer from
16
+ polluting output. An analyzer can be as trigger-happy as it wants when
17
+ proposing candidates -- the engine is what keeps the signal-to-noise ratio
18
+ high, and it does so via a rule, not a vibe.
19
+
20
+ Confidence is a deterministic function over the *set* of evidence types
21
+ present on a candidate (see `score_candidate`). It is intentionally NOT a
22
+ black-box/ML score -- security practitioners don't trust "73% confidence"
23
+ without a reason, so JScope always shows the reasoning chain that produced
24
+ a score, not just the number.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import itertools
30
+ from dataclasses import dataclass, field
31
+ from enum import Enum
32
+ from typing import Any, Iterable
33
+
34
+
35
+ # --------------------------------------------------------------------------
36
+ # Evidence vocabulary
37
+ # --------------------------------------------------------------------------
38
+
39
+ class EvidenceType(str, Enum):
40
+ """Fixed vocabulary of evidence kinds. See jscope/docs/EVIDENCE_SCHEMA.md
41
+ for the versioned spec that plugin authors should target."""
42
+
43
+ # --- positive evidence (increases confidence) ---
44
+ LITERAL_STRING_MATCH = "LITERAL_STRING_MATCH" # weakest signal
45
+ NAMING_CONVENTION = "NAMING_CONVENTION" # e.g. /admin/, /_debug/
46
+ AST_PATTERN_MATCH = "AST_PATTERN_MATCH" # matches a known call/shape
47
+ IMPORT_CHAIN = "IMPORT_CHAIN" # resolved through import/export graph
48
+ SYMBOL_RESOLVED = "SYMBOL_RESOLVED" # value resolved through a variable, not a raw literal
49
+ FRAMEWORK_CONTEXT = "FRAMEWORK_CONTEXT" # a FrameworkProfile corroborates this
50
+ DATA_FLOW = "DATA_FLOW" # tracked from source to sink
51
+ DATA_FLOW_PARTIAL = "DATA_FLOW_PARTIAL" # source->sink path that relies on an assumption or an uncertain validator (0.5)
52
+ CALL_GRAPH_REACHABLE = "CALL_GRAPH_REACHABLE" # reachable from a known entry point
53
+ CROSS_ANALYZER_AGREEMENT = "CROSS_ANALYZER_AGREEMENT" # 2+ independent analyzers agree
54
+ ARTIFACT_RESOLVED = "ARTIFACT_RESOLVED" # a referenced artifact (source map, worker script, module) was found and parsed
55
+ RUNTIME_CONTEXT = "RUNTIME_CONTEXT" # confirmed via opt-in runtime probe (Phase 4: `jscope confirm`)
56
+
57
+ # --- negative evidence (decreases confidence / suppresses) ---
58
+ DEAD_CODE = "DEAD_CODE" # gated behind if(false)/unreachable branch
59
+ UNREACHABLE = "UNREACHABLE" # no discovered call path from any entry point
60
+ TEST_FIXTURE_CONTEXT = "TEST_FIXTURE_CONTEXT" # lives in *.test.js, __tests__/, __mocks__/
61
+ MOCK_OR_PLACEHOLDER = "MOCK_OR_PLACEHOLDER" # e.g. process.env.X never substituted
62
+ VENDOR_CODE = "VENDOR_CODE" # matched a known vendor/library fingerprint
63
+ RUNTIME_NOT_FOUND = "RUNTIME_NOT_FOUND" # opt-in runtime probe got a genuine not-found (Phase 4)
64
+
65
+
66
+ # Deterministic weight table. This is the ENTIRE scoring model for v1 -- no
67
+ # hidden ML layer. Changing a weight is a reviewable, diffable, documented
68
+ # decision (see "confidence-level changelog discipline" in the design doc).
69
+ EVIDENCE_WEIGHTS: dict[EvidenceType, int] = {
70
+ EvidenceType.LITERAL_STRING_MATCH: 1,
71
+ EvidenceType.NAMING_CONVENTION: 1,
72
+ EvidenceType.AST_PATTERN_MATCH: 2,
73
+ EvidenceType.IMPORT_CHAIN: 2,
74
+ EvidenceType.SYMBOL_RESOLVED: 3,
75
+ EvidenceType.FRAMEWORK_CONTEXT: 2,
76
+ EvidenceType.DATA_FLOW: 3,
77
+ EvidenceType.DATA_FLOW_PARTIAL: 1,
78
+ EvidenceType.CALL_GRAPH_REACHABLE: 3,
79
+ EvidenceType.CROSS_ANALYZER_AGREEMENT: 4,
80
+ EvidenceType.ARTIFACT_RESOLVED: 3,
81
+ EvidenceType.RUNTIME_CONTEXT: 6,
82
+ # negative
83
+ EvidenceType.DEAD_CODE: -4,
84
+ EvidenceType.UNREACHABLE: -3,
85
+ EvidenceType.TEST_FIXTURE_CONTEXT: -3,
86
+ EvidenceType.MOCK_OR_PLACEHOLDER: -3,
87
+ EvidenceType.VENDOR_CODE: -2,
88
+ EvidenceType.RUNTIME_NOT_FOUND: -3,
89
+ }
90
+
91
+ # Diminishing returns: a 2nd/3rd piece of evidence of the *same* type is
92
+ # worth less than the 1st. Prevents an analyzer from "farming" confidence
93
+ # by emitting ten near-duplicate LITERAL_STRING_MATCH evidence for one
94
+ # candidate. Index 0 = 1st occurrence multiplier, index 1 = 2nd, etc.
95
+ _DIMINISHING_RETURNS = [1.0, 0.5, 0.25]
96
+ _DIMINISHING_FLOOR = 0.1
97
+
98
+
99
+ class Confidence(str, Enum):
100
+ CONFIRMED = "CONFIRMED"
101
+ HIGH = "HIGH"
102
+ MEDIUM = "MEDIUM"
103
+ LOW = "LOW"
104
+ INFORMATIONAL = "INFORMATIONAL"
105
+ SUPPRESSED = "SUPPRESSED" # never shown by default; below the noise floor
106
+
107
+
108
+ # Score thresholds -> confidence level. Deliberately conservative: reaching
109
+ # CONFIRMED without a runtime probe requires several independently-typed,
110
+ # agreeing pieces of evidence, not just "a lot of the same weak thing".
111
+ _THRESHOLDS: list[tuple[int, Confidence]] = [
112
+ (10, Confidence.CONFIRMED),
113
+ (7, Confidence.HIGH),
114
+ (4, Confidence.MEDIUM),
115
+ (2, Confidence.LOW),
116
+ (1, Confidence.INFORMATIONAL),
117
+ ]
118
+
119
+ MIN_DISPLAY_SCORE = 1 # candidates scoring below this are SUPPRESSED entirely
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class Evidence:
124
+ """A single, typed, attributable piece of evidence for a candidate."""
125
+
126
+ type: EvidenceType
127
+ description: str
128
+ analyzer: str
129
+ file: str | None = None
130
+ line: int | None = None
131
+ snippet: str | None = None
132
+ metadata: dict[str, Any] = field(default_factory=dict)
133
+
134
+ @property
135
+ def weight(self) -> int:
136
+ return EVIDENCE_WEIGHTS[self.type]
137
+
138
+ def to_dict(self) -> dict[str, Any]:
139
+ return {
140
+ "type": self.type.value,
141
+ "description": self.description,
142
+ "analyzer": self.analyzer,
143
+ "file": self.file,
144
+ "line": self.line,
145
+ "snippet": self.snippet,
146
+ "weight": self.weight,
147
+ "metadata": self.metadata,
148
+ }
149
+
150
+
151
+ @dataclass
152
+ class Candidate:
153
+ """What an analyzer proposes. NOT visible to the user until/unless the
154
+ EvidenceEngine promotes it to a Finding."""
155
+
156
+ category: str # e.g. "endpoint", "secret", "auth", "framework"
157
+ subject: str # the artifact itself, e.g. the URL or key name -- used for de-dup
158
+ analyzer: str
159
+ evidence: list[Evidence] = field(default_factory=list)
160
+ attack_surface: str | None = None # e.g. "admin", "idor_candidate", "payments"
161
+ suggested_test: str | None = None
162
+ related: list[str] = field(default_factory=list) # subjects of related candidates
163
+ metadata: dict[str, Any] = field(default_factory=dict)
164
+
165
+ @property
166
+ def dedup_key(self) -> tuple[str, str]:
167
+ return (self.category, self.subject)
168
+
169
+
170
+ @dataclass
171
+ class Finding:
172
+ """A user-visible, promoted result. Every Finding is fully reconstructible
173
+ back to the evidence that produced it -- that's the whole point."""
174
+
175
+ id: str
176
+ category: str
177
+ subject: str
178
+ confidence: Confidence
179
+ score: int
180
+ evidence: list[Evidence]
181
+ contributing_analyzers: list[str]
182
+ attack_surface: str | None
183
+ suggested_test: str | None
184
+ reasoning: list[str]
185
+ related: list[str] = field(default_factory=list)
186
+ metadata: dict[str, Any] = field(default_factory=dict)
187
+
188
+ def to_dict(self) -> dict[str, Any]:
189
+ from jscope.core.classification import classify # local import: classification imports jscope.__version__ only
190
+ return {
191
+ "id": self.id,
192
+ "classification": classify(self.category, self.metadata, {e.type.value for e in self.evidence}, self.confidence.value),
193
+ "category": self.category,
194
+ "subject": self.subject,
195
+ "confidence": self.confidence.value,
196
+ "score": self.score,
197
+ "attack_surface": self.attack_surface,
198
+ "suggested_test": self.suggested_test,
199
+ "reasoning": self.reasoning,
200
+ "contributing_analyzers": self.contributing_analyzers,
201
+ "related": self.related,
202
+ "metadata": self.metadata,
203
+ "evidence": [e.to_dict() for e in self.evidence],
204
+ }
205
+
206
+
207
+ def score_evidence(evidence: Iterable[Evidence]) -> tuple[int, list[str]]:
208
+ """Deterministic scoring function. Returns (score, reasoning_lines).
209
+
210
+ Reasoning lines are meant to be shown verbatim to the user -- this is
211
+ what makes a score legible instead of a magic number.
212
+ """
213
+ evidence = list(evidence)
214
+ reasoning: list[str] = []
215
+ total = 0.0
216
+
217
+ by_type: dict[EvidenceType, list[Evidence]] = {}
218
+ for e in evidence:
219
+ by_type.setdefault(e.type, []).append(e)
220
+
221
+ for etype, items in by_type.items():
222
+ base_weight = EVIDENCE_WEIGHTS[etype]
223
+ for i, item in enumerate(items):
224
+ if base_weight >= 0:
225
+ multiplier = _DIMINISHING_RETURNS[i] if i < len(_DIMINISHING_RETURNS) else _DIMINISHING_FLOOR
226
+ else:
227
+ multiplier = 1.0 # penalties are never discounted
228
+ contribution = base_weight * multiplier
229
+ total += contribution
230
+ if i == 0:
231
+ reasoning.append(
232
+ f"{etype.value} (+{contribution:g}): {item.description}"
233
+ if contribution >= 0
234
+ else f"{etype.value} ({contribution:g}): {item.description}"
235
+ )
236
+ else:
237
+ reasoning.append(
238
+ f"{etype.value} #{i + 1} (+{contribution:g}, diminished): {item.description}"
239
+ )
240
+
241
+ return round(total), reasoning
242
+
243
+
244
+ def confidence_for_score(score: int) -> Confidence:
245
+ if score < MIN_DISPLAY_SCORE:
246
+ return Confidence.SUPPRESSED
247
+ for threshold, level in _THRESHOLDS:
248
+ if score >= threshold:
249
+ return level
250
+ return Confidence.SUPPRESSED
251
+
252
+
253
+ _LEVEL = {
254
+ Confidence.CONFIRMED: 5, Confidence.HIGH: 4, Confidence.MEDIUM: 3,
255
+ Confidence.LOW: 2, Confidence.INFORMATIONAL: 1, Confidence.SUPPRESSED: 0,
256
+ }
257
+
258
+
259
+ class EvidenceEngine:
260
+ """Collects candidates from all analyzers, merges duplicates (adding
261
+ CROSS_ANALYZER_AGREEMENT evidence automatically when independent
262
+ analyzers converge on the same subject), scores them, and promotes
263
+ the ones that clear the bar into Findings.
264
+ """
265
+
266
+ def __init__(self, min_display_confidence: Confidence = Confidence.LOW,
267
+ analyzer_tiers: dict[str, str] | None = None,
268
+ community_cap: Confidence = Confidence.MEDIUM):
269
+ self._candidates: dict[tuple[str, str], Candidate] = {}
270
+ self._order: list[tuple[str, str]] = []
271
+ self.min_display_confidence = min_display_confidence
272
+ # analyzer name -> "core" | "verified" | "community". A candidate whose
273
+ # contributing analyzers are ALL community-tier is capped at `community_cap`:
274
+ # unreviewed plugins can inform a finding but cannot, alone, make it HIGH/CONFIRMED.
275
+ self.analyzer_tiers = analyzer_tiers or {}
276
+ self.community_cap = community_cap
277
+
278
+ def propose(self, candidate: Candidate) -> None:
279
+ """An analyzer calls this. It does NOT create a finding."""
280
+ if not isinstance(candidate, Candidate):
281
+ raise TypeError(
282
+ f"propose() only accepts Candidate objects, got {type(candidate).__name__}; "
283
+ "analyzers cannot emit Findings"
284
+ )
285
+ key = candidate.dedup_key
286
+ if key not in self._candidates:
287
+ self._candidates[key] = candidate
288
+ self._order.append(key)
289
+ return
290
+
291
+ existing = self._candidates[key]
292
+ existing.evidence.extend(candidate.evidence)
293
+ existing.related.extend(candidate.related)
294
+ if candidate.attack_surface and not existing.attack_surface:
295
+ existing.attack_surface = candidate.attack_surface
296
+ if candidate.suggested_test and not existing.suggested_test:
297
+ existing.suggested_test = candidate.suggested_test
298
+ existing.metadata.update(candidate.metadata)
299
+
300
+ def _apply_cross_analyzer_agreement(self, candidate: Candidate) -> list[Evidence]:
301
+ analyzers = {e.analyzer for e in candidate.evidence}
302
+ if len(analyzers) < 2:
303
+ return []
304
+ return [
305
+ Evidence(
306
+ type=EvidenceType.CROSS_ANALYZER_AGREEMENT,
307
+ description=(
308
+ f"{len(analyzers)} independent analyzers agree on this subject: "
309
+ + ", ".join(sorted(analyzers))
310
+ ),
311
+ analyzer="evidence_engine",
312
+ )
313
+ ]
314
+
315
+ def promote(self) -> list[Finding]:
316
+ """Run scoring + promotion over every proposed candidate. Idempotent-ish:
317
+ safe to call once after all analyzers have run."""
318
+ findings: list[Finding] = []
319
+ counters = itertools.count(1)
320
+
321
+ order_levels = _LEVEL
322
+ min_level = order_levels[self.min_display_confidence]
323
+
324
+ for key in self._order:
325
+ candidate = self._candidates[key]
326
+ auto_evidence = self._apply_cross_analyzer_agreement(candidate)
327
+ all_evidence = candidate.evidence + auto_evidence
328
+ score, reasoning = score_evidence(all_evidence)
329
+ confidence = confidence_for_score(score)
330
+
331
+ contributing = sorted({e.analyzer for e in candidate.evidence})
332
+ tiers = {a: self.analyzer_tiers.get(a, "core") for a in contributing}
333
+ if (contributing and all(t == "community" for t in tiers.values())
334
+ and order_levels[confidence] > order_levels[self.community_cap]):
335
+ reasoning.append(
336
+ f"CAPPED at {self.community_cap.value}: every contributing analyzer is "
337
+ f"community-tier ({', '.join(contributing)}); needs a core/verified analyzer to rise further"
338
+ )
339
+ confidence = self.community_cap
340
+
341
+ if order_levels[confidence] < min_level:
342
+ continue # still SUPPRESSED/below threshold -- never shown
343
+
344
+ finding_id = f"JS-{next(counters):04d}"
345
+ findings.append(
346
+ Finding(
347
+ id=finding_id,
348
+ category=candidate.category,
349
+ subject=candidate.subject,
350
+ confidence=confidence,
351
+ score=score,
352
+ evidence=all_evidence,
353
+ contributing_analyzers=contributing,
354
+ attack_surface=candidate.attack_surface,
355
+ suggested_test=candidate.suggested_test,
356
+ reasoning=reasoning,
357
+ related=sorted(set(candidate.related)),
358
+ metadata={**candidate.metadata, "analyzer_tiers": tiers},
359
+ )
360
+ )
361
+
362
+ # Stable, useful default ordering: confidence desc, then score desc.
363
+ level_rank = {c: i for i, (_, c) in enumerate(_THRESHOLDS)}
364
+ findings.sort(key=lambda f: (level_rank.get(f.confidence, 99), -f.score))
365
+ return findings
366
+
367
+ @property
368
+ def candidate_count(self) -> int:
369
+ return len(self._candidates)
370
+
371
+
372
+ # --------------------------------------------------------------------------
373
+ # Runtime evidence (Phase 4)
374
+ # --------------------------------------------------------------------------
375
+
376
+ RUNTIME_ANALYZER = "runtime_prober"
377
+ _RUNTIME_TYPES = {EvidenceType.RUNTIME_CONTEXT, EvidenceType.RUNTIME_NOT_FOUND}
378
+
379
+
380
+ def apply_runtime_evidence(finding: Finding, extra: list[Evidence],
381
+ community_cap: Confidence = Confidence.MEDIUM) -> Finding:
382
+ """Re-score an existing Finding with evidence from the opt-in runtime prober.
383
+
384
+ This is the ONLY door through which RUNTIME_* evidence reaches a finding. The AnalyzerManager strips
385
+ these types from every analyzer (core, verified and community), so a plugin cannot fake a live
386
+ confirmation; this function in turn accepts nothing except evidence attributed to the prober.
387
+ """
388
+ for e in extra:
389
+ if e.type not in _RUNTIME_TYPES or e.analyzer != RUNTIME_ANALYZER:
390
+ raise ValueError("apply_runtime_evidence only accepts RUNTIME_* evidence from the runtime prober")
391
+ base = [e for e in finding.evidence if e.type not in _RUNTIME_TYPES] # re-probing replaces old probe results
392
+ all_evidence = base + list(extra)
393
+ score, reasoning = score_evidence(all_evidence)
394
+ confidence = confidence_for_score(score)
395
+ if confidence == Confidence.SUPPRESSED: # a live probe downgrades a finding; it never silently deletes one
396
+ confidence = Confidence.INFORMATIONAL
397
+ reasoning.append("FLOORED at INFORMATIONAL: runtime evidence lowers confidence but does not remove the finding")
398
+ contributing = sorted({e.analyzer for e in all_evidence if e.analyzer != "evidence_engine"})
399
+ tiers = dict(finding.metadata.get("analyzer_tiers", {}))
400
+ for a in contributing:
401
+ tiers.setdefault(a, "core")
402
+ if all(tiers.get(a) == "community" for a in contributing) and _LEVEL[confidence] > _LEVEL[community_cap]:
403
+ reasoning.append(f"CAPPED at {community_cap.value}: every contributing analyzer is community-tier")
404
+ confidence = community_cap
405
+ meta = dict(finding.metadata)
406
+ meta["analyzer_tiers"] = tiers
407
+ meta["runtime_checked"] = True
408
+ return Finding(
409
+ id=finding.id, category=finding.category, subject=finding.subject, confidence=confidence, score=score,
410
+ evidence=all_evidence, contributing_analyzers=contributing, attack_surface=finding.attack_surface,
411
+ suggested_test=finding.suggested_test, reasoning=reasoning, related=list(finding.related), metadata=meta)
jscope/core/graph.py ADDED
@@ -0,0 +1,196 @@
1
+ """Application Knowledge Graph.
2
+
3
+ Pages -> components -> routes -> APIs -> auth -> roles -> storage -> services.
4
+
5
+ Per the design doc, the graph exists primarily to improve analysis accuracy
6
+ (correlating routes with the APIs they reach, finding unguarded surfaces),
7
+ not for visualization. It stores FACTS an analyzer observed in the AST, each
8
+ edge tagged with the analyzer and the evidence type that justified it. It is
9
+ not a findings channel: nothing here is user-visible as a finding unless an
10
+ analyzer ALSO proposes a Candidate through the Evidence Engine.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import deque
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from typing import Any
19
+
20
+
21
+ class NodeKind(str, Enum):
22
+ ROUTE = "route"
23
+ COMPONENT = "component"
24
+ FILE = "file"
25
+ ENDPOINT = "endpoint"
26
+ GUARD = "guard"
27
+ ROLE = "role"
28
+ STORAGE = "storage"
29
+ SERVICE = "service"
30
+ PAGE = "page" # an HTML page / Vue SFC (Phase 4)
31
+ FORM = "form" # an HTML <form> (Phase 4)
32
+ SPEC_OPERATION = "spec_operation" # an OpenAPI operation (Phase 4)
33
+ DATA_SOURCE = "data_source" # a tainted input (0.5 FR-05)
34
+ DATA_SINK = "data_sink" # a security-sensitive sink (0.5 FR-05)
35
+ FINDING = "finding" # a promoted Finding, linked back into the graph (0.5 FR-05)
36
+ RUNTIME_REQUEST = "runtime_request" # an observed runtime request (0.5 FR-05)
37
+
38
+
39
+ class EdgeKind(str, Enum):
40
+ RENDERS = "renders" # route -> component | file
41
+ CONTAINS = "contains" # file -> component
42
+ CALLS = "calls" # component | file -> endpoint
43
+ GUARDED_BY = "guarded_by" # route -> guard
44
+ REQUIRES_ROLE = "requires_role" # route -> role (declared by route config)
45
+ CHECKS_ROLE = "checks_role" # component -> role (checked in code)
46
+ USES_STORAGE = "uses_storage" # component | file -> storage
47
+ USES_SERVICE = "uses_service" # component | file -> service
48
+ LOADS = "loads" # page -> file (script tag / module import) (Phase 4)
49
+ CONTAINS_FORM = "contains_form" # page -> form (Phase 4)
50
+ SUBMITS_TO = "submits_to" # form -> endpoint (Phase 4)
51
+ DOCUMENTS = "documents" # spec_operation -> endpoint (Phase 4)
52
+ FLOWS_TO = "flows_to" # data_source -> data_sink (0.5 FR-05)
53
+ DESCRIBES = "describes" # data_sink | endpoint -> finding (0.5 FR-05)
54
+ SUPPORTED_BY = "supported_by" # finding -> file (evidence location) (0.5 FR-05)
55
+ OBSERVED_AT = "observed_at" # runtime_request -> endpoint (0.5 FR-05)
56
+ VERIFIED_BY = "verified_by" # finding -> finding (verification result attached, self-referential note) (0.5 FR-05)
57
+
58
+
59
+ # edges followed when asking "what does this route reach?"
60
+ _REACH_EDGES = {EdgeKind.RENDERS, EdgeKind.CONTAINS, EdgeKind.CALLS, EdgeKind.CHECKS_ROLE,
61
+ EdgeKind.USES_STORAGE, EdgeKind.USES_SERVICE}
62
+
63
+
64
+ @dataclass
65
+ class Node:
66
+ id: str
67
+ kind: NodeKind
68
+ label: str
69
+ attrs: dict[str, Any] = field(default_factory=dict)
70
+
71
+
72
+ @dataclass
73
+ class Edge:
74
+ src: str
75
+ dst: str
76
+ kind: EdgeKind
77
+ analyzer: str
78
+ basis: str # EvidenceType name that justified the edge
79
+ file: str | None = None
80
+ line: int | None = None
81
+ note: str | None = None
82
+
83
+
84
+ class KnowledgeGraph:
85
+ def __init__(self) -> None:
86
+ self.nodes: dict[str, Node] = {}
87
+ self._edges: dict[tuple[str, str, EdgeKind], Edge] = {}
88
+ self._out: dict[str, list[Edge]] = {}
89
+ self._in: dict[str, list[Edge]] = {}
90
+
91
+ # -- mutation -----------------------------------------------------------
92
+ def add_node(self, id: str, kind: NodeKind, label: str | None = None, **attrs: Any) -> Node:
93
+ n = self.nodes.get(id)
94
+ if n is None:
95
+ n = Node(id, kind, label or id, dict(attrs))
96
+ self.nodes[id] = n
97
+ else:
98
+ for k, v in attrs.items():
99
+ # list attrs accumulate (deduped); scalars: first writer wins unless falsy
100
+ if isinstance(v, list):
101
+ cur = n.attrs.setdefault(k, [])
102
+ for item in v:
103
+ if item not in cur:
104
+ cur.append(item)
105
+ elif not n.attrs.get(k):
106
+ n.attrs[k] = v
107
+ return n
108
+
109
+ def add_edge(self, src: str, dst: str, kind: EdgeKind, analyzer: str, basis: str,
110
+ file: str | None = None, line: int | None = None, note: str | None = None) -> None:
111
+ if src not in self.nodes or dst not in self.nodes:
112
+ raise KeyError(f"edge endpoints must exist first: {src} -> {dst}")
113
+ key = (src, dst, kind)
114
+ if key in self._edges:
115
+ return
116
+ e = Edge(src, dst, kind, analyzer, basis, file, line, note)
117
+ self._edges[key] = e
118
+ self._out.setdefault(src, []).append(e)
119
+ self._in.setdefault(dst, []).append(e)
120
+
121
+ # -- queries ------------------------------------------------------------
122
+ def out_edges(self, node_id: str, kind: EdgeKind | None = None) -> list[Edge]:
123
+ return [e for e in self._out.get(node_id, []) if kind is None or e.kind == kind]
124
+
125
+ def in_edges(self, node_id: str, kind: EdgeKind | None = None) -> list[Edge]:
126
+ return [e for e in self._in.get(node_id, []) if kind is None or e.kind == kind]
127
+
128
+ def by_kind(self, kind: NodeKind) -> list[Node]:
129
+ return [n for n in self.nodes.values() if n.kind == kind]
130
+
131
+ def reachable_from(self, node_id: str, edge_kinds: set[EdgeKind] | None = None) -> set[str]:
132
+ edge_kinds = edge_kinds or _REACH_EDGES
133
+ seen, q = {node_id}, deque([node_id])
134
+ while q:
135
+ cur = q.popleft()
136
+ for e in self._out.get(cur, []):
137
+ if e.kind in edge_kinds and e.dst not in seen:
138
+ seen.add(e.dst)
139
+ q.append(e.dst)
140
+ seen.discard(node_id)
141
+ return seen
142
+
143
+ def routes_reaching(self, node_id: str) -> list[Node]:
144
+ """Routes from which `node_id` (e.g. an endpoint) is reachable."""
145
+ return [r for r in self.by_kind(NodeKind.ROUTE) if node_id in self.reachable_from(r.id)]
146
+
147
+ def route_protection(self, route_id: str) -> dict[str, Any]:
148
+ guards = [self.nodes[e.dst].label for e in self.out_edges(route_id, EdgeKind.GUARDED_BY)]
149
+ roles = [self.nodes[e.dst].label for e in self.out_edges(route_id, EdgeKind.REQUIRES_ROLE)]
150
+ return {"guards": guards, "roles": roles, "unguarded": not guards and not roles}
151
+
152
+ def route_chains(self) -> list[dict[str, Any]]:
153
+ """One dict per route: protection + everything reachable from it."""
154
+ out = []
155
+ for r in sorted(self.by_kind(NodeKind.ROUTE), key=lambda n: n.label):
156
+ reach = self.reachable_from(r.id)
157
+ by = lambda k: sorted(self.nodes[i].label for i in reach if self.nodes[i].kind == k)
158
+ prot = self.route_protection(r.id)
159
+ out.append({
160
+ "route": r.label, "id": r.id, **prot,
161
+ "components": by(NodeKind.COMPONENT) + by(NodeKind.FILE),
162
+ "endpoints": by(NodeKind.ENDPOINT),
163
+ "roles_checked": by(NodeKind.ROLE),
164
+ "storage": by(NodeKind.STORAGE),
165
+ "services": by(NodeKind.SERVICE),
166
+ "framework": r.attrs.get("framework"),
167
+ "file": r.attrs.get("file"),
168
+ })
169
+ return out
170
+
171
+ # -- (de)serialization --------------------------------------------------
172
+ def to_dict(self) -> dict[str, Any]:
173
+ return {
174
+ "nodes": [{"id": n.id, "kind": n.kind.value, "label": n.label, "attrs": n.attrs}
175
+ for n in self.nodes.values()],
176
+ "edges": [{"src": e.src, "dst": e.dst, "kind": e.kind.value, "analyzer": e.analyzer,
177
+ "basis": e.basis, "file": e.file, "line": e.line, "note": e.note}
178
+ for e in self._edges.values()],
179
+ }
180
+
181
+ @classmethod
182
+ def from_dict(cls, d: dict[str, Any]) -> "KnowledgeGraph":
183
+ g = cls()
184
+ for n in d.get("nodes", []):
185
+ g.add_node(n["id"], NodeKind(n["kind"]), n["label"], **n.get("attrs", {}))
186
+ for e in d.get("edges", []):
187
+ g.add_edge(e["src"], e["dst"], EdgeKind(e["kind"]), e["analyzer"], e["basis"],
188
+ e.get("file"), e.get("line"), e.get("note"))
189
+ return g
190
+
191
+ def stats(self) -> dict[str, int]:
192
+ out: dict[str, int] = {}
193
+ for n in self.nodes.values():
194
+ out[n.kind.value] = out.get(n.kind.value, 0) + 1
195
+ out["edges"] = len(self._edges)
196
+ return out
jscope/core/redact.py ADDED
@@ -0,0 +1,67 @@
1
+ """Redaction rules (FR-08, NFR-03). Applied to everything runtime/verification code stores or exports.
2
+
3
+ Rules are documented in docs/VERIFICATION.md. Redaction is by default and not optional for exported evidence:
4
+ * Authorization / Cookie / Set-Cookie / X-API-Key style headers -> value replaced
5
+ * query-string and form parameters whose NAME looks sensitive -> value replaced
6
+ * JWTs, `Bearer ...`, AWS/Stripe/GitHub/Slack-style keys, long hex/base64 runs after a secret-ish key -> replaced
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
13
+
14
+ MASK = "[REDACTED]"
15
+ SENSITIVE_HEADERS = {"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", "x-csrf-token",
16
+ "x-xsrf-token", "x-amz-security-token", "api-key", "apikey"}
17
+ SENSITIVE_PARAM = re.compile(r"(token|secret|passw(or)?d|pwd|credential|api[_-]?key|session|sid|auth|jwt|signature|otp|code)$", re.I)
18
+ _PATTERNS = [
19
+ (re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*"), "[REDACTED-JWT]"),
20
+ (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{8,}"), "Bearer " + MASK),
21
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "[REDACTED-AWS-KEY]"),
22
+ (re.compile(r"\b(sk|rk|pk)_(live|test)_[A-Za-z0-9]{10,}\b"), "[REDACTED-STRIPE-KEY]"),
23
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"), "[REDACTED-GITHUB-TOKEN]"),
24
+ (re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"), "[REDACTED-SLACK-TOKEN]"),
25
+ (re.compile(r"(?i)((?:token|secret|passw(?:or)?d|api[_-]?key|session(?:id)?)\s*[=:]\s*)[\"']?[^\s\"'&;,]{6,}"), r"\1" + MASK),
26
+ ]
27
+
28
+
29
+ def redact_text(text: str | None) -> str | None:
30
+ if not text:
31
+ return text
32
+ for pat, rep in _PATTERNS:
33
+ text = pat.sub(rep, text)
34
+ return text
35
+
36
+
37
+ def redact_headers(headers: dict | None) -> dict:
38
+ out = {}
39
+ for k, v in (headers or {}).items():
40
+ out[k] = MASK if k.lower() in SENSITIVE_HEADERS else redact_text(str(v))
41
+ return out
42
+
43
+
44
+ def redact_url(url: str) -> str:
45
+ try:
46
+ u = urlsplit(url)
47
+ q = [(k, MASK if SENSITIVE_PARAM.search(k) else redact_text(v)) for k, v in parse_qsl(u.query, keep_blank_values=True)]
48
+ netloc = u.netloc.rsplit("@", 1)[-1]
49
+ return urlunsplit((u.scheme, netloc, u.path, urlencode(q, safe="[]"), u.fragment))
50
+ except ValueError:
51
+ return redact_text(url) or ""
52
+
53
+
54
+ def redact_storage_value(key: str, value: str) -> str:
55
+ return MASK if SENSITIVE_PARAM.search(key) or re.search(r"(?i)(token|secret|jwt|auth|session)", key) else (redact_text(value) or "")[:200]
56
+
57
+
58
+ def redact_obj(obj):
59
+ """Deep redaction of dict/list/str structures (used for request/response evidence)."""
60
+ if isinstance(obj, dict):
61
+ return {k: (MASK if isinstance(k, str) and (k.lower() in SENSITIVE_HEADERS or SENSITIVE_PARAM.search(k)) and isinstance(v, str)
62
+ else redact_obj(v)) for k, v in obj.items()}
63
+ if isinstance(obj, list):
64
+ return [redact_obj(x) for x in obj]
65
+ if isinstance(obj, str):
66
+ return redact_text(obj)
67
+ return obj
File without changes