skill-sentry-cli 2.0.0__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.
- skill_sentry_cli/__init__.py +5 -0
- skill_sentry_cli/__main__.py +4 -0
- skill_sentry_cli/cli.py +918 -0
- skill_sentry_cli/core/__init__.py +1 -0
- skill_sentry_cli/core/anchors/__init__.py +5 -0
- skill_sentry_cli/core/anchors/credential_flow.py +440 -0
- skill_sentry_cli/core/anchors/declared_hosts.py +78 -0
- skill_sentry_cli/core/cache/__init__.py +35 -0
- skill_sentry_cli/core/cache/hash_utils.py +206 -0
- skill_sentry_cli/core/cache/sqlite_store.py +925 -0
- skill_sentry_cli/core/config.py +299 -0
- skill_sentry_cli/core/contracts.py +551 -0
- skill_sentry_cli/core/decisions/__init__.py +44 -0
- skill_sentry_cli/core/decisions/base.py +174 -0
- skill_sentry_cli/core/decisions/calibration.py +325 -0
- skill_sentry_cli/core/decisions/laya.py +439 -0
- skill_sentry_cli/core/decisions/mapping.py +218 -0
- skill_sentry_cli/core/decisions/mock.py +118 -0
- skill_sentry_cli/core/decisions/questions.py +118 -0
- skill_sentry_cli/core/decisions/report.py +65 -0
- skill_sentry_cli/core/decisions/selector.py +151 -0
- skill_sentry_cli/core/engine.py +1412 -0
- skill_sentry_cli/core/evasion/__init__.py +27 -0
- skill_sentry_cli/core/evasion/detector.py +210 -0
- skill_sentry_cli/core/exit_codes.py +13 -0
- skill_sentry_cli/core/models/__init__.py +20 -0
- skill_sentry_cli/core/models/_json.py +147 -0
- skill_sentry_cli/core/models/base.py +50 -0
- skill_sentry_cli/core/models/gemma_cloud.py +240 -0
- skill_sentry_cli/core/models/gemma_local.py +273 -0
- skill_sentry_cli/core/models/image_utils.py +112 -0
- skill_sentry_cli/core/models/mock.py +152 -0
- skill_sentry_cli/core/models/selector.py +123 -0
- skill_sentry_cli/core/parser/__init__.py +29 -0
- skill_sentry_cli/core/parser/skill_parser.py +520 -0
- skill_sentry_cli/core/parser/typosquat.py +167 -0
- skill_sentry_cli/core/poisoned_well/__init__.py +52 -0
- skill_sentry_cli/core/poisoned_well/attacker_profile.py +230 -0
- skill_sentry_cli/core/poisoned_well/canary_gen.py +413 -0
- skill_sentry_cli/core/poisoned_well/exfil_tracker.py +470 -0
- skill_sentry_cli/core/reports.py +114 -0
- skill_sentry_cli/core/sandbox/__init__.py +74 -0
- skill_sentry_cli/core/sandbox/assets/Dockerfile.sandbox +39 -0
- skill_sentry_cli/core/sandbox/assets/masquerade.c +484 -0
- skill_sentry_cli/core/sandbox/assets/run_skill.sh +41 -0
- skill_sentry_cli/core/sandbox/assets/sinkhole_sidecar.py +171 -0
- skill_sentry_cli/core/sandbox/canary_fs.py +108 -0
- skill_sentry_cli/core/sandbox/context_honeypot.py +97 -0
- skill_sentry_cli/core/sandbox/dns_sinkhole.py +218 -0
- skill_sentry_cli/core/sandbox/fake_http.py +194 -0
- skill_sentry_cli/core/sandbox/instructions.py +170 -0
- skill_sentry_cli/core/sandbox/masquerade.py +265 -0
- skill_sentry_cli/core/sandbox/monitor.py +278 -0
- skill_sentry_cli/core/sandbox/runner.py +776 -0
- skill_sentry_cli/core/sandbox/sidecar.py +327 -0
- skill_sentry_cli/core/semantic/__init__.py +9 -0
- skill_sentry_cli/core/semantic/analyzer.py +443 -0
- skill_sentry_cli/core/semantic/ast_anchor.py +371 -0
- skill_sentry_cli/core/semantic/ast_python.py +156 -0
- skill_sentry_cli/core/semantic/candidates.py +78 -0
- skill_sentry_cli/core/semantic/laya_analyzer.py +241 -0
- skill_sentry_cli/core/semantic/prompts.py +61 -0
- skill_sentry_cli/core/verdict/__init__.py +24 -0
- skill_sentry_cli/core/verdict/fusion.py +392 -0
- skill_sentry_cli/core/verdict/markdown_report.py +211 -0
- skill_sentry_cli/core/verdict/sarif_report.py +139 -0
- skill_sentry_cli/core/verdict/status.py +35 -0
- skill_sentry_cli/core/verdict/stix_report.py +132 -0
- skill_sentry_cli/core/version.py +38 -0
- skill_sentry_cli/core/vision/__init__.py +8 -0
- skill_sentry_cli/core/vision/image_extractor.py +91 -0
- skill_sentry_cli/core/vision/vision_scanner.py +274 -0
- skill_sentry_cli/data/__init__.py +1 -0
- skill_sentry_cli/data/known_skills_v1.json +276 -0
- skill_sentry_cli/data/sandbox_image.json +4 -0
- skill_sentry_cli/rules/credential_rules.toml +94 -0
- skill_sentry_cli/server/__init__.py +5 -0
- skill_sentry_cli/server/app.py +129 -0
- skill_sentry_cli/server/backend.py +63 -0
- skill_sentry_cli/server/demo_assets.py +62 -0
- skill_sentry_cli/server/demo_backend.py +475 -0
- skill_sentry_cli/server/demo_data.py +814 -0
- skill_sentry_cli/server/demo_mode.py +40 -0
- skill_sentry_cli/server/errors.py +48 -0
- skill_sentry_cli/server/live_backend.py +576 -0
- skill_sentry_cli/server/routes.py +191 -0
- skill_sentry_cli/skill/SKILL.md +67 -0
- skill_sentry_cli/skill/scripts/audit.sh +48 -0
- skill_sentry_cli/ui/dist/assets/index-Bf0h24IX.css +1 -0
- skill_sentry_cli/ui/dist/assets/index-CrYBKSSB.js +8 -0
- skill_sentry_cli/ui/dist/index.html +14 -0
- skill_sentry_cli-2.0.0.dist-info/METADATA +76 -0
- skill_sentry_cli-2.0.0.dist-info/RECORD +96 -0
- skill_sentry_cli-2.0.0.dist-info/WHEEL +4 -0
- skill_sentry_cli-2.0.0.dist-info/entry_points.txt +2 -0
- skill_sentry_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Weighted signal fusion and hard-evidence overrides (docs/02 §2.4).
|
|
2
|
+
|
|
3
|
+
The rules are intentionally small and explicit:
|
|
4
|
+
|
|
5
|
+
* missing signal sources are renormalised over the sources that did run;
|
|
6
|
+
* a typosquat finding adds its configured bonus after the weighted average;
|
|
7
|
+
* findings impose severity floors: CRITICAL >= 95 (MALICIOUS), HIGH >= 70
|
|
8
|
+
(SUSPICIOUS boundary), MEDIUM >= 30 (SUSPICIOUS boundary). A single strong
|
|
9
|
+
deterministic/vision finding must never be diluted below SAFE by a large
|
|
10
|
+
number of clean-looking signals;
|
|
11
|
+
* Poisoned Well / evasion overrides force ``MALICIOUS`` with confidence 1.0;
|
|
12
|
+
evasion reports 100 because it is a direct observation of hostile intent;
|
|
13
|
+
* a degraded stage can never leave a scan looking ``SAFE``;
|
|
14
|
+
* if no stage produced a signal at all, :class:`FusionError` is raised rather
|
|
15
|
+
than fabricating a clean verdict.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Iterable, Mapping
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from skill_sentry_cli.core.contracts import (
|
|
24
|
+
Finding,
|
|
25
|
+
FindingEvidence,
|
|
26
|
+
OverrideSignal,
|
|
27
|
+
Severity,
|
|
28
|
+
Stage,
|
|
29
|
+
StageResult,
|
|
30
|
+
VerdictTier,
|
|
31
|
+
)
|
|
32
|
+
from skill_sentry_cli.core.version import DEFAULT_WEIGHTS
|
|
33
|
+
|
|
34
|
+
_TIER_RANK = {
|
|
35
|
+
VerdictTier.SAFE: 0,
|
|
36
|
+
VerdictTier.SUSPICIOUS: 1,
|
|
37
|
+
VerdictTier.MALICIOUS: 2,
|
|
38
|
+
}
|
|
39
|
+
# Evidence floors, not weights. The score remains the primary explanation;
|
|
40
|
+
# these ensure that explicit hostile evidence cannot be averaged away.
|
|
41
|
+
SEVERITY_FLOORS: dict[Severity, int] = {
|
|
42
|
+
Severity.CRITICAL: 95,
|
|
43
|
+
Severity.HIGH: 70,
|
|
44
|
+
Severity.MEDIUM: 30,
|
|
45
|
+
Severity.LOW: 0,
|
|
46
|
+
Severity.NOTE: 0,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FusionError(RuntimeError):
|
|
51
|
+
"""The stage results cannot produce a trustworthy verdict."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class FusionResult:
|
|
56
|
+
"""Outcome of one fusion pass.
|
|
57
|
+
|
|
58
|
+
``findings`` includes the stage findings plus an explicit note for every
|
|
59
|
+
degraded stage. ``missing_signals`` is retained for the caller to surface
|
|
60
|
+
in logs/telemetry; fusion itself renormalises over present signals.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
risk_score: int
|
|
64
|
+
verdict: VerdictTier
|
|
65
|
+
confidence: float
|
|
66
|
+
signals: dict[str, float] = field(default_factory=dict)
|
|
67
|
+
findings: tuple[Finding, ...] = ()
|
|
68
|
+
override: OverrideSignal | None = None
|
|
69
|
+
degraded_stages: tuple[Stage, ...] = ()
|
|
70
|
+
missing_signals: tuple[str, ...] = ()
|
|
71
|
+
typosquat_bonus: int = 0
|
|
72
|
+
coverage: float = 0.0
|
|
73
|
+
evidence_floor: int = 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fuse(
|
|
77
|
+
stage_results: Iterable[StageResult],
|
|
78
|
+
*,
|
|
79
|
+
weights: Mapping[str, float] | None = None,
|
|
80
|
+
typosquat_bonus: int = 15,
|
|
81
|
+
safe_max: int = 30,
|
|
82
|
+
malicious_min: int = 70,
|
|
83
|
+
typosquat_detected: bool | None = None,
|
|
84
|
+
typosquat_floor: int = 30,
|
|
85
|
+
) -> FusionResult:
|
|
86
|
+
"""Fuse stage results into a risk score and tier.
|
|
87
|
+
|
|
88
|
+
``weights`` uses the four canonical keys (``anchors``, ``semantic``,
|
|
89
|
+
``vision``, ``sandbox``). Any source absent from the results is omitted
|
|
90
|
+
from the denominator, so partial scans remain useful without manufacturing
|
|
91
|
+
a zero contribution.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
results = tuple(stage_results)
|
|
95
|
+
if not results:
|
|
96
|
+
raise FusionError("cannot fuse an empty stage-result list")
|
|
97
|
+
if typosquat_bonus < 0:
|
|
98
|
+
raise ValueError("typosquat_bonus must be non-negative")
|
|
99
|
+
if not 0 <= safe_max <= 100:
|
|
100
|
+
raise ValueError("safe_max must be between 0 and 100")
|
|
101
|
+
if not 0 <= malicious_min <= 100:
|
|
102
|
+
raise ValueError("malicious_min must be between 0 and 100")
|
|
103
|
+
if safe_max >= malicious_min:
|
|
104
|
+
raise ValueError("safe_max must be lower than malicious_min")
|
|
105
|
+
if not 0 <= typosquat_floor <= 100:
|
|
106
|
+
raise ValueError("typosquat_floor must be between 0 and 100")
|
|
107
|
+
|
|
108
|
+
normalised_weights = _normalise_weights(weights)
|
|
109
|
+
signals, typo_signal = _collect_signals(results, normalised_weights)
|
|
110
|
+
# Typosquat is a bonus, not a weighted signal, even if a caller leaves it
|
|
111
|
+
# in a custom weight map.
|
|
112
|
+
present = tuple(key for key in normalised_weights if key != "typosquat" and key in signals)
|
|
113
|
+
detected = (
|
|
114
|
+
typosquat_detected
|
|
115
|
+
if typosquat_detected is not None
|
|
116
|
+
else _typosquat_detected(results, typo_signal)
|
|
117
|
+
)
|
|
118
|
+
override = _select_override(results)
|
|
119
|
+
degraded = tuple(result.stage for result in results if result.degraded or result.error)
|
|
120
|
+
if not present and not detected and override is None and not degraded:
|
|
121
|
+
raise FusionError("no usable stage signals were produced; refusing to emit a SAFE verdict")
|
|
122
|
+
|
|
123
|
+
denominator = sum(normalised_weights[key] for key in present)
|
|
124
|
+
if present and denominator <= 0:
|
|
125
|
+
raise FusionError("fusion weights sum to zero")
|
|
126
|
+
|
|
127
|
+
weighted = (
|
|
128
|
+
sum(normalised_weights[key] * signals[key] for key in present) / denominator
|
|
129
|
+
if present
|
|
130
|
+
else 0.0
|
|
131
|
+
)
|
|
132
|
+
bonus = int(typosquat_bonus) if detected else 0
|
|
133
|
+
score = _clamp_score(round(weighted) + bonus)
|
|
134
|
+
findings = _collect_findings(results)
|
|
135
|
+
evidence_floor = _evidence_floor(
|
|
136
|
+
[finding for finding in findings if finding.stage != Stage.SEMANTIC],
|
|
137
|
+
detected=detected,
|
|
138
|
+
typosquat_floor=typosquat_floor,
|
|
139
|
+
)
|
|
140
|
+
# Model-reported findings can raise a verdict to SUSPICIOUS, never to MALICIOUS.
|
|
141
|
+
model_floor = min(
|
|
142
|
+
_evidence_floor(
|
|
143
|
+
[finding for finding in findings if finding.stage == Stage.SEMANTIC],
|
|
144
|
+
detected=False,
|
|
145
|
+
typosquat_floor=0,
|
|
146
|
+
),
|
|
147
|
+
malicious_min,
|
|
148
|
+
)
|
|
149
|
+
evidence_floor = max(evidence_floor, model_floor)
|
|
150
|
+
score = max(score, evidence_floor)
|
|
151
|
+
coverage = (
|
|
152
|
+
sum(normalised_weights[key] for key in present) / sum(normalised_weights.values())
|
|
153
|
+
if present
|
|
154
|
+
else 0.0
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
tier = score_to_verdict(score, safe_max=safe_max, malicious_min=malicious_min)
|
|
158
|
+
if override is not None:
|
|
159
|
+
if override.verdict == VerdictTier.MALICIOUS:
|
|
160
|
+
# Every malicious override applies its own floor, so a Poisoned Well
|
|
161
|
+
# hit cannot hide an evasion probe that reports a higher floor.
|
|
162
|
+
malicious_floor = max(_override_floor(item) for item in _malicious_overrides(results))
|
|
163
|
+
score = max(score, malicious_floor)
|
|
164
|
+
tier = VerdictTier.MALICIOUS
|
|
165
|
+
else:
|
|
166
|
+
tier = _max_tier(tier, override.verdict)
|
|
167
|
+
if tier == VerdictTier.SUSPICIOUS:
|
|
168
|
+
score = max(score, safe_max)
|
|
169
|
+
|
|
170
|
+
# A scan with a failed stage is not clean, even when the usable signals are
|
|
171
|
+
# all zero. The note finding makes the reason visible instead of silently
|
|
172
|
+
# lowering the score.
|
|
173
|
+
if degraded and tier == VerdictTier.SAFE:
|
|
174
|
+
score = max(score, safe_max)
|
|
175
|
+
tier = VerdictTier.SUSPICIOUS
|
|
176
|
+
|
|
177
|
+
# A MALICIOUS tier needs evidence that is not model output alone: a malicious
|
|
178
|
+
# override, or a non-semantic finding of HIGH or above. Otherwise the model's
|
|
179
|
+
# verdict is capped at the SUSPICIOUS boundary and the cap is stated.
|
|
180
|
+
if (
|
|
181
|
+
tier == VerdictTier.MALICIOUS
|
|
182
|
+
and override is None
|
|
183
|
+
and not _has_strong_non_semantic_evidence(findings, signals, detected=detected)
|
|
184
|
+
):
|
|
185
|
+
tier = VerdictTier.SUSPICIOUS
|
|
186
|
+
score = min(score, malicious_min)
|
|
187
|
+
findings = (*findings, _semantic_cap_note())
|
|
188
|
+
|
|
189
|
+
malicious_override = override is not None and override.verdict == VerdictTier.MALICIOUS
|
|
190
|
+
if malicious_override:
|
|
191
|
+
confidence = 1.0
|
|
192
|
+
elif override is not None:
|
|
193
|
+
confidence = max(float(override.confidence), coverage)
|
|
194
|
+
else:
|
|
195
|
+
confidence = round(min(1.0, max(0.0, coverage)), 3)
|
|
196
|
+
if degraded and not malicious_override:
|
|
197
|
+
confidence = min(confidence, 0.5)
|
|
198
|
+
|
|
199
|
+
return FusionResult(
|
|
200
|
+
risk_score=score,
|
|
201
|
+
verdict=tier,
|
|
202
|
+
confidence=confidence,
|
|
203
|
+
signals=signals,
|
|
204
|
+
findings=findings,
|
|
205
|
+
override=override,
|
|
206
|
+
degraded_stages=degraded,
|
|
207
|
+
missing_signals=tuple(key for key in normalised_weights if key not in signals),
|
|
208
|
+
typosquat_bonus=bonus,
|
|
209
|
+
coverage=round(coverage, 3),
|
|
210
|
+
evidence_floor=evidence_floor,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def score_to_verdict(
|
|
215
|
+
score: int | float,
|
|
216
|
+
*,
|
|
217
|
+
safe_max: int = 30,
|
|
218
|
+
malicious_min: int = 70,
|
|
219
|
+
) -> VerdictTier:
|
|
220
|
+
"""Map a 0-100 score to the frozen tier boundaries."""
|
|
221
|
+
|
|
222
|
+
if not 0 <= safe_max <= 100:
|
|
223
|
+
raise ValueError("safe_max must be between 0 and 100")
|
|
224
|
+
if not 0 <= malicious_min <= 100:
|
|
225
|
+
raise ValueError("malicious_min must be between 0 and 100")
|
|
226
|
+
if safe_max >= malicious_min:
|
|
227
|
+
raise ValueError("safe_max must be lower than malicious_min")
|
|
228
|
+
value = _clamp_score(round(score))
|
|
229
|
+
if value < safe_max:
|
|
230
|
+
return VerdictTier.SAFE
|
|
231
|
+
if value > malicious_min:
|
|
232
|
+
return VerdictTier.MALICIOUS
|
|
233
|
+
return VerdictTier.SUSPICIOUS
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def degradation_findings(stage_results: Iterable[StageResult]) -> list[Finding]:
|
|
237
|
+
"""Return explicit note findings for stages that failed or degraded."""
|
|
238
|
+
|
|
239
|
+
findings: list[Finding] = []
|
|
240
|
+
for result in stage_results:
|
|
241
|
+
if not (result.degraded or result.error):
|
|
242
|
+
continue
|
|
243
|
+
detail = result.error or "stage marked degraded"
|
|
244
|
+
findings.append(
|
|
245
|
+
Finding(
|
|
246
|
+
stage=result.stage,
|
|
247
|
+
severity=Severity.NOTE,
|
|
248
|
+
message=f"{result.stage.value} degraded: {detail}",
|
|
249
|
+
finding_type="stage-degraded",
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return findings
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _has_strong_non_semantic_evidence(
|
|
256
|
+
findings: Iterable[Finding],
|
|
257
|
+
signals: Mapping[str, float],
|
|
258
|
+
*,
|
|
259
|
+
detected: bool,
|
|
260
|
+
) -> bool:
|
|
261
|
+
if detected:
|
|
262
|
+
return True
|
|
263
|
+
strong_signal = any(
|
|
264
|
+
key != "semantic" and value >= SEVERITY_FLOORS[Severity.HIGH]
|
|
265
|
+
for key, value in signals.items()
|
|
266
|
+
)
|
|
267
|
+
strong_finding = any(
|
|
268
|
+
finding.stage != Stage.SEMANTIC and finding.severity.rank >= Severity.HIGH.rank
|
|
269
|
+
for finding in findings
|
|
270
|
+
)
|
|
271
|
+
return strong_signal or strong_finding
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _semantic_cap_note() -> Finding:
|
|
275
|
+
return Finding(
|
|
276
|
+
stage=Stage.VERDICT,
|
|
277
|
+
severity=Severity.NOTE,
|
|
278
|
+
message=(
|
|
279
|
+
"Verdict capped at SUSPICIOUS: the only malicious evidence is model-reported. "
|
|
280
|
+
"A MALICIOUS verdict needs a deterministic, sandbox, or vision finding."
|
|
281
|
+
),
|
|
282
|
+
type="semantic-only-capped",
|
|
283
|
+
evidence=FindingEvidence(file="SKILL.md", line=1),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _evidence_floor(findings: Iterable[Finding], *, detected: bool, typosquat_floor: int) -> int:
|
|
288
|
+
floor = int(typosquat_floor) if detected else 0
|
|
289
|
+
for finding in findings:
|
|
290
|
+
floor = max(floor, SEVERITY_FLOORS.get(finding.severity, 0))
|
|
291
|
+
return floor
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _normalise_weights(weights: Mapping[str, float] | None) -> dict[str, float]:
|
|
295
|
+
source = dict(weights or DEFAULT_WEIGHTS)
|
|
296
|
+
normalised: dict[str, float] = {}
|
|
297
|
+
for key, raw in source.items():
|
|
298
|
+
if key == "typosquat":
|
|
299
|
+
continue
|
|
300
|
+
value = float(raw)
|
|
301
|
+
if value < 0:
|
|
302
|
+
raise ValueError(f"weight for {key!r} must be non-negative")
|
|
303
|
+
if value == 0:
|
|
304
|
+
continue
|
|
305
|
+
normalised[str(key)] = value
|
|
306
|
+
if not normalised:
|
|
307
|
+
raise ValueError("at least one fusion weight must be positive")
|
|
308
|
+
return normalised
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _collect_signals(
|
|
312
|
+
results: tuple[StageResult, ...], weights: Mapping[str, float]
|
|
313
|
+
) -> tuple[dict[str, float], float]:
|
|
314
|
+
signals: dict[str, float] = {}
|
|
315
|
+
typo_signal = 0.0
|
|
316
|
+
for result in results:
|
|
317
|
+
# A stage may emit more than one weighted key (e.g. a combined
|
|
318
|
+
# deterministic stage); take the maximum rather than double-counting.
|
|
319
|
+
for key, raw in result.signals.items():
|
|
320
|
+
if key not in weights and key != "typosquat":
|
|
321
|
+
continue
|
|
322
|
+
value = max(0.0, min(100.0, float(raw)))
|
|
323
|
+
signals[key] = max(signals.get(key, 0.0), value)
|
|
324
|
+
if key == "typosquat":
|
|
325
|
+
typo_signal = max(typo_signal, value)
|
|
326
|
+
return signals, typo_signal
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _typosquat_detected(results: tuple[StageResult, ...], typo_signal: float) -> bool:
|
|
330
|
+
if typo_signal > 0:
|
|
331
|
+
return True
|
|
332
|
+
for result in results:
|
|
333
|
+
if result.stage != Stage.TYPOSQUAT:
|
|
334
|
+
continue
|
|
335
|
+
if result.findings or result.signals.get("typosquat", 0) > 0:
|
|
336
|
+
return True
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _override_floor(item: OverrideSignal) -> int:
|
|
341
|
+
"""Score floor for a malicious override.
|
|
342
|
+
|
|
343
|
+
Evasion is direct proof of adversarial behaviour; Poisoned Well is proof of
|
|
344
|
+
exfiltration. Both are malicious, but the documented slack fixture requires
|
|
345
|
+
the evasion score to reach 100.
|
|
346
|
+
"""
|
|
347
|
+
return 100 if item.source == Stage.EVASION else 95
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _malicious_overrides(results: tuple[StageResult, ...]) -> list[OverrideSignal]:
|
|
351
|
+
return [
|
|
352
|
+
result.override
|
|
353
|
+
for result in results
|
|
354
|
+
if result.override is not None and result.override.verdict == VerdictTier.MALICIOUS
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _select_override(results: tuple[StageResult, ...]) -> OverrideSignal | None:
|
|
359
|
+
overrides = [result.override for result in results if result.override is not None]
|
|
360
|
+
if not overrides:
|
|
361
|
+
return None
|
|
362
|
+
malicious = [item for item in overrides if item.verdict == VerdictTier.MALICIOUS]
|
|
363
|
+
if malicious:
|
|
364
|
+
# Confidence first; on a tie, the stronger floor (evasion) is reported.
|
|
365
|
+
return max(malicious, key=lambda item: (item.confidence, _override_floor(item)))
|
|
366
|
+
return max(overrides, key=lambda item: item.confidence)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _max_tier(left: VerdictTier, right: VerdictTier) -> VerdictTier:
|
|
370
|
+
return left if _TIER_RANK[left] >= _TIER_RANK[right] else right
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _collect_findings(results: tuple[StageResult, ...]) -> tuple[Finding, ...]:
|
|
374
|
+
findings: list[Finding] = []
|
|
375
|
+
for result in results:
|
|
376
|
+
findings.extend(result.findings)
|
|
377
|
+
findings.extend(degradation_findings(results))
|
|
378
|
+
return tuple(findings)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _clamp_score(value: int) -> int:
|
|
382
|
+
return max(0, min(100, value))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
__all__ = [
|
|
386
|
+
"SEVERITY_FLOORS",
|
|
387
|
+
"FusionError",
|
|
388
|
+
"FusionResult",
|
|
389
|
+
"degradation_findings",
|
|
390
|
+
"fuse",
|
|
391
|
+
"score_to_verdict",
|
|
392
|
+
]
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Human-readable Markdown triage report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from skill_sentry_cli.core.contracts import (
|
|
11
|
+
ActiveDefense,
|
|
12
|
+
Finding,
|
|
13
|
+
Severity,
|
|
14
|
+
StageResult,
|
|
15
|
+
Verdict,
|
|
16
|
+
format_utc,
|
|
17
|
+
utc_now,
|
|
18
|
+
)
|
|
19
|
+
from skill_sentry_cli.core.verdict.status import degraded_reason, is_unscanned, shown_status
|
|
20
|
+
from skill_sentry_cli.core.version import ENGINE_VERSION, GEMMA_BADGE
|
|
21
|
+
|
|
22
|
+
_SEVERITY_ORDER = [
|
|
23
|
+
Severity.CRITICAL,
|
|
24
|
+
Severity.HIGH,
|
|
25
|
+
Severity.MEDIUM,
|
|
26
|
+
Severity.LOW,
|
|
27
|
+
Severity.NOTE,
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cell(value: object) -> str:
|
|
32
|
+
return str(value).replace("|", "\\|").replace("\n", " ")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _code(value: str) -> str:
|
|
36
|
+
return "`" + value.replace("`", "\\`") + "`"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _evidence_line(finding: Finding) -> str:
|
|
40
|
+
evidence = finding.evidence
|
|
41
|
+
parts: list[str] = []
|
|
42
|
+
if evidence.file:
|
|
43
|
+
location = evidence.file
|
|
44
|
+
if evidence.line:
|
|
45
|
+
location += f":{evidence.line}"
|
|
46
|
+
parts.append(_code(location))
|
|
47
|
+
for key in ("c2", "marker", "domain", "ip", "url", "path", "channel", "process"):
|
|
48
|
+
value = getattr(evidence, key, None)
|
|
49
|
+
if value:
|
|
50
|
+
parts.append(f"{key}={_code(str(value))}")
|
|
51
|
+
if evidence.ocr:
|
|
52
|
+
parts.append(f"ocr={_code(evidence.ocr)}")
|
|
53
|
+
if evidence.bbox:
|
|
54
|
+
parts.append("bbox=" + _code(str(evidence.bbox)))
|
|
55
|
+
return " · ".join(parts) if parts else "—"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _active_defense_section(active: ActiveDefense | None) -> list[str]:
|
|
59
|
+
lines = ["## ☠️ Active Defense (Poisoned Well)", ""]
|
|
60
|
+
if active is None:
|
|
61
|
+
lines.append("_Sandbox/deception stages did not run for this scan._")
|
|
62
|
+
return lines
|
|
63
|
+
if not active.triggered:
|
|
64
|
+
lines.append("No active-defense events — nothing touched the honeypots ✓")
|
|
65
|
+
if active.sandbox_evasion_detected:
|
|
66
|
+
lines.append("")
|
|
67
|
+
lines.append("**⚠️ Sandbox evasion probes were detected.**")
|
|
68
|
+
return lines
|
|
69
|
+
|
|
70
|
+
lines += [
|
|
71
|
+
"| Field | Value |",
|
|
72
|
+
"|:---|:---|",
|
|
73
|
+
f"| Targeted assets | {_cell(', '.join(active.targeted_assets) or '—')} |",
|
|
74
|
+
f"| Exfil channel | {_cell(active.exfil_channel.value if active.exfil_channel else '—')} |",
|
|
75
|
+
f"| Canary marker | {_cell(active.canary_marker or '—')} |",
|
|
76
|
+
f"| Secondary canary | {_cell(active.secondary_canary or '—')} |",
|
|
77
|
+
f"| C2 indicators | {_cell(', '.join(active.c2_indicators) or '—')} |",
|
|
78
|
+
f"| Encoding | {_cell(active.encoding or '—')} |",
|
|
79
|
+
f"| Declared intent | {_cell(active.declared_intent or '—')} |",
|
|
80
|
+
f"| Actual behavior | {_cell(active.actual_behavior or '—')} |",
|
|
81
|
+
f"| Context-aware canary | {_cell(active.context_aware_canary.value if active.context_aware_canary else '—')} |",
|
|
82
|
+
f"| Sandbox evasion | {'yes' if active.sandbox_evasion_detected else 'no'} |",
|
|
83
|
+
]
|
|
84
|
+
if active.ioc_export:
|
|
85
|
+
lines += ["", f"STIX bundle: {_code(active.ioc_export)}"]
|
|
86
|
+
return lines
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _decision_lines(decision: dict[str, Any] | None) -> list[str]:
|
|
90
|
+
if decision is None:
|
|
91
|
+
return ["## Decision layer", "", "Not recorded for this export.", ""]
|
|
92
|
+
error = decision.get("semantic_error")
|
|
93
|
+
semantic = (
|
|
94
|
+
f"degraded: {html.escape(str(error))}" if decision.get("semantic_degraded") else "complete"
|
|
95
|
+
)
|
|
96
|
+
threshold = decision.get("threshold")
|
|
97
|
+
lines = [
|
|
98
|
+
"## Decision layer",
|
|
99
|
+
"",
|
|
100
|
+
"| Field | Value |",
|
|
101
|
+
"|:---|:---|",
|
|
102
|
+
f"| Semantic backend | {_code(str(decision.get('decision_backend')))} |",
|
|
103
|
+
f"| Checkpoint | {_code(str(decision.get('checkpoint') or 'n/a'))} |",
|
|
104
|
+
f"| Model version | {_code(str(decision.get('model_version') or 'n/a'))} |",
|
|
105
|
+
f"| Calibration | {_code(str(decision.get('calibration_version')))} |",
|
|
106
|
+
f"| Capability threshold | {threshold if threshold is not None else 'n/a'} |",
|
|
107
|
+
f"| Semantic stage | {semantic} |",
|
|
108
|
+
f"| Vision | {_code(str(decision.get('vision_status')))} |",
|
|
109
|
+
"",
|
|
110
|
+
]
|
|
111
|
+
capabilities = decision.get("capabilities") or {}
|
|
112
|
+
if capabilities:
|
|
113
|
+
lines += ["Capability probabilities (advisory, not verdicts):", ""]
|
|
114
|
+
lines += [f"- `{qid}`: {value:.2f}" for qid, value in sorted(capabilities.items())]
|
|
115
|
+
lines.append("")
|
|
116
|
+
return lines
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_markdown(
|
|
120
|
+
verdict: Verdict,
|
|
121
|
+
*,
|
|
122
|
+
generated_at: datetime | None = None,
|
|
123
|
+
decision: dict[str, Any] | None = None,
|
|
124
|
+
stage_results: Sequence[StageResult] | None = None,
|
|
125
|
+
) -> str:
|
|
126
|
+
"""Render a human triage report with line-accurate citations.
|
|
127
|
+
|
|
128
|
+
``decision`` is the report-safe summary from
|
|
129
|
+
:func:`skill_sentry_cli.core.decisions.report.decision_summary`. Omit it and the
|
|
130
|
+
report says the decision layer was not recorded rather than implying clean.
|
|
131
|
+
"""
|
|
132
|
+
generated = format_utc(generated_at or utc_now())
|
|
133
|
+
lines: list[str] = [
|
|
134
|
+
f"# SkillSentry Triage Report — {html.escape(verdict.skill)}",
|
|
135
|
+
"",
|
|
136
|
+
"| Field | Value |",
|
|
137
|
+
"|:---|:---|",
|
|
138
|
+
f"| Verdict | **{shown_status(verdict.verdict, stage_results)}** |",
|
|
139
|
+
f"| Risk score | {verdict.risk_score} / 100 |",
|
|
140
|
+
f"| Confidence | {verdict.confidence:.2f} |",
|
|
141
|
+
f"| Cached | {'⚡ yes' if verdict.cached else 'no'} |",
|
|
142
|
+
f"| Scan duration | {verdict.scan_duration_ms} ms |",
|
|
143
|
+
f"| Skill ID | {_code(str(verdict.skill_id))} |",
|
|
144
|
+
f"| Scan ID | {_code(str(verdict.scan_id))} |",
|
|
145
|
+
f"| Engine | {_code(ENGINE_VERSION)} |",
|
|
146
|
+
f"| Generated | {generated} |",
|
|
147
|
+
"",
|
|
148
|
+
]
|
|
149
|
+
lines += _decision_lines(decision)
|
|
150
|
+
lines += ["## Findings", ""]
|
|
151
|
+
|
|
152
|
+
if not verdict.findings:
|
|
153
|
+
lines.append("No findings. The skill is within the SAFE threshold.")
|
|
154
|
+
else:
|
|
155
|
+
for severity in _SEVERITY_ORDER:
|
|
156
|
+
group = [f for f in verdict.findings if f.severity == severity]
|
|
157
|
+
if not group:
|
|
158
|
+
continue
|
|
159
|
+
lines.append(f"### {severity.value.upper()} ({len(group)})")
|
|
160
|
+
lines.append("")
|
|
161
|
+
for finding in group:
|
|
162
|
+
stage = finding.stage.value
|
|
163
|
+
if decision is not None and finding.stage.value == "semantic":
|
|
164
|
+
stage = f"{stage} ({decision.get('decision_backend')})"
|
|
165
|
+
kind = f" · `{finding.finding_type}`" if finding.finding_type else ""
|
|
166
|
+
lines.append(f"- **{stage}**{kind} — {html.escape(finding.message)}")
|
|
167
|
+
lines.append(f" - Evidence: {_evidence_line(finding)}")
|
|
168
|
+
lines.append("")
|
|
169
|
+
|
|
170
|
+
lines.append("")
|
|
171
|
+
lines += _active_defense_section(verdict.active_defense)
|
|
172
|
+
|
|
173
|
+
unscanned = is_unscanned(verdict.verdict, stage_results)
|
|
174
|
+
if unscanned:
|
|
175
|
+
lines += [
|
|
176
|
+
"",
|
|
177
|
+
"## Recommended action",
|
|
178
|
+
"",
|
|
179
|
+
"**Do not install until the scan completes.** The verdict "
|
|
180
|
+
f"({verdict.verdict.value}) is based on an incomplete scan: "
|
|
181
|
+
f"{degraded_reason(stage_results)}. Resolve the cause and scan again.",
|
|
182
|
+
]
|
|
183
|
+
elif verdict.verdict.value == "MALICIOUS":
|
|
184
|
+
lines += [
|
|
185
|
+
"",
|
|
186
|
+
"## Recommended action",
|
|
187
|
+
"",
|
|
188
|
+
"**Do not install this skill.** Preserve the SARIF/STIX exports and review the cited "
|
|
189
|
+
"`file:line` evidence.",
|
|
190
|
+
]
|
|
191
|
+
elif verdict.verdict.value == "SUSPICIOUS":
|
|
192
|
+
lines += [
|
|
193
|
+
"",
|
|
194
|
+
"## Recommended action",
|
|
195
|
+
"",
|
|
196
|
+
"Review every cited finding with the skill's author before installing.",
|
|
197
|
+
]
|
|
198
|
+
else:
|
|
199
|
+
lines += ["", "## Recommended action", "", "Safe to install."]
|
|
200
|
+
|
|
201
|
+
lines += [
|
|
202
|
+
"",
|
|
203
|
+
"---",
|
|
204
|
+
"",
|
|
205
|
+
f"*{GEMMA_BADGE}* · Generated by SkillSentry 2.0",
|
|
206
|
+
"",
|
|
207
|
+
]
|
|
208
|
+
return "\n".join(lines)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
__all__ = ["build_markdown"]
|