jscope 0.4.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jscope/__init__.py +1 -0
- jscope/__main__.py +10 -0
- jscope/ai/__init__.py +0 -0
- jscope/ai/explain.py +180 -0
- jscope/ai/providers.py +71 -0
- jscope/analyzers/__init__.py +0 -0
- jscope/analyzers/auth_analyzer.py +170 -0
- jscope/analyzers/authorization_analyzer.py +196 -0
- jscope/analyzers/base.py +56 -0
- jscope/analyzers/common.py +78 -0
- jscope/analyzers/dataflow_analyzer.py +192 -0
- jscope/analyzers/endpoint_analyzer.py +150 -0
- jscope/analyzers/framework_detector.py +82 -0
- jscope/analyzers/graphql_analyzer.py +198 -0
- jscope/analyzers/graphql_schema_analyzer.py +132 -0
- jscope/analyzers/html_analyzer.py +196 -0
- jscope/analyzers/manager.py +114 -0
- jscope/analyzers/openapi_analyzer.py +278 -0
- jscope/analyzers/router_analyzer.py +502 -0
- jscope/analyzers/secret_analyzer.py +99 -0
- jscope/analyzers/service_worker_analyzer.py +142 -0
- jscope/analyzers/source_map_analyzer.py +119 -0
- jscope/analyzers/storage_analyzer.py +125 -0
- jscope/analyzers/wasm_analyzer.py +139 -0
- jscope/assessment_engine.py +285 -0
- jscope/assessment_reporting.py +71 -0
- jscope/benchmark/__init__.py +1 -0
- jscope/benchmark/fixtures.py +105 -0
- jscope/benchmark/runner.py +155 -0
- jscope/cli.py +1241 -0
- jscope/config.py +72 -0
- jscope/core/__init__.py +0 -0
- jscope/core/artifacts.py +139 -0
- jscope/core/cache.py +198 -0
- jscope/core/classification.py +56 -0
- jscope/core/evidence.py +411 -0
- jscope/core/graph.py +196 -0
- jscope/core/redact.py +67 -0
- jscope/crawler/__init__.py +0 -0
- jscope/crawler/crawler.py +176 -0
- jscope/dataflow/__init__.py +1 -0
- jscope/dataflow/engine.py +1684 -0
- jscope/dataflow/modules.py +205 -0
- jscope/dataflow/rules.py +118 -0
- jscope/downloader/__init__.py +0 -0
- jscope/downloader/downloader.py +116 -0
- jscope/parsing/__init__.py +0 -0
- jscope/parsing/ast_engine.py +322 -0
- jscope/parsing/graphql_schema.py +153 -0
- jscope/parsing/html.py +55 -0
- jscope/parsing/queries.py +385 -0
- jscope/parsing/wasm.py +146 -0
- jscope/passive_discovery.py +63 -0
- jscope/pipeline.py +196 -0
- jscope/plugin/__init__.py +0 -0
- jscope/plugin/_worker.py +199 -0
- jscope/plugin/loader.py +225 -0
- jscope/plugin/sandbox.py +207 -0
- jscope/reporting/__init__.py +0 -0
- jscope/reporting/formats.py +208 -0
- jscope/reporting/human.py +137 -0
- jscope/reporting/json_report.py +35 -0
- jscope/reporting/priority.py +116 -0
- jscope/runtime/__init__.py +0 -0
- jscope/runtime/observe.py +397 -0
- jscope/runtime/prober.py +259 -0
- jscope/runtime/scope.py +288 -0
- jscope/security_transport.py +246 -0
- jscope/verify/__init__.py +1 -0
- jscope/verify/engine.py +290 -0
- jscope/workspace/__init__.py +0 -0
- jscope/workspace/assessment.py +133 -0
- jscope/workspace/common.py +25 -0
- jscope/workspace/diff.py +143 -0
- jscope/workspace/store.py +206 -0
- jscope-0.4.2.dist-info/METADATA +180 -0
- jscope-0.4.2.dist-info/RECORD +81 -0
- jscope-0.4.2.dist-info/WHEEL +5 -0
- jscope-0.4.2.dist-info/entry_points.txt +2 -0
- jscope-0.4.2.dist-info/licenses/LICENSE +21 -0
- jscope-0.4.2.dist-info/top_level.txt +1 -0
jscope/core/evidence.py
ADDED
|
@@ -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
|