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,139 @@
|
|
|
1
|
+
"""SARIF 2.1.0 report writer (docs/05-api-reference.md §5.6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from skill_sentry_cli.core.contracts import Finding, Severity, Verdict, format_utc, utc_now
|
|
10
|
+
from skill_sentry_cli.core.version import ENGINE_VERSION
|
|
11
|
+
|
|
12
|
+
SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
13
|
+
LEVEL_BY_SEVERITY: dict[Severity, str] = {
|
|
14
|
+
Severity.CRITICAL: "error",
|
|
15
|
+
Severity.HIGH: "error",
|
|
16
|
+
Severity.MEDIUM: "warning",
|
|
17
|
+
Severity.LOW: "note",
|
|
18
|
+
Severity.NOTE: "note",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _slug(value: str, *, fallback: str = "finding", max_length: int = 64) -> str:
|
|
23
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
24
|
+
return (slug or fallback)[:max_length].strip("-") or fallback
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def rule_id(finding: Finding) -> str:
|
|
28
|
+
"""``skillsentry/<stage>/<type>`` — §5.6."""
|
|
29
|
+
kind = finding.finding_type or _slug(finding.message)
|
|
30
|
+
return f"skillsentry/{finding.stage.value}/{_slug(kind)}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _artifact_uri(path: str | None) -> str | None:
|
|
34
|
+
if not path:
|
|
35
|
+
return None
|
|
36
|
+
normalised = path.replace("\\", "/").lstrip("/")
|
|
37
|
+
while normalised.startswith("./"):
|
|
38
|
+
normalised = normalised[2:]
|
|
39
|
+
return quote(normalised, safe="/-._~")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _result(finding: Finding, rule_index: int) -> dict[str, Any]:
|
|
43
|
+
result: dict[str, Any] = {
|
|
44
|
+
"ruleId": rule_id(finding),
|
|
45
|
+
"ruleIndex": rule_index,
|
|
46
|
+
"level": LEVEL_BY_SEVERITY[finding.severity],
|
|
47
|
+
"message": {"text": finding.message},
|
|
48
|
+
"properties": {
|
|
49
|
+
"stage": finding.stage.value,
|
|
50
|
+
"severity": finding.severity.value,
|
|
51
|
+
"evidence": finding.evidence.model_dump(by_alias=True, mode="json", exclude_none=True),
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
uri = _artifact_uri(finding.evidence.file)
|
|
55
|
+
if uri:
|
|
56
|
+
physical: dict[str, Any] = {"artifactLocation": {"uri": uri}}
|
|
57
|
+
if finding.evidence.line:
|
|
58
|
+
physical["region"] = {"startLine": finding.evidence.line}
|
|
59
|
+
result["locations"] = [{"physicalLocation": physical}]
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_sarif(
|
|
64
|
+
verdict: Verdict,
|
|
65
|
+
*,
|
|
66
|
+
generated_at=None,
|
|
67
|
+
decision: dict[str, Any] | None = None,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""Build a schema-shaped SARIF 2.1.0 document for one verdict."""
|
|
70
|
+
rules: dict[str, dict[str, Any]] = {}
|
|
71
|
+
results: list[dict[str, Any]] = []
|
|
72
|
+
|
|
73
|
+
for finding in verdict.findings:
|
|
74
|
+
rid = rule_id(finding)
|
|
75
|
+
if rid not in rules:
|
|
76
|
+
rules[rid] = {
|
|
77
|
+
"id": rid,
|
|
78
|
+
"name": _slug(rid.rsplit("/", 1)[-1], fallback="finding"),
|
|
79
|
+
"shortDescription": {"text": finding.message},
|
|
80
|
+
"helpUri": "https://github.com/skillsentry/skillsentry#readme",
|
|
81
|
+
"defaultConfiguration": {"level": LEVEL_BY_SEVERITY[finding.severity]},
|
|
82
|
+
"properties": {
|
|
83
|
+
"stage": finding.stage.value,
|
|
84
|
+
"severity": finding.severity.value,
|
|
85
|
+
"tags": [finding.stage.value, finding.severity.value],
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
results.append(_result(finding, list(rules).index(rid)))
|
|
89
|
+
|
|
90
|
+
run_properties: dict[str, Any] = {
|
|
91
|
+
"verdict": verdict.verdict.value,
|
|
92
|
+
"riskScore": verdict.risk_score,
|
|
93
|
+
"confidence": verdict.confidence,
|
|
94
|
+
"cached": verdict.cached,
|
|
95
|
+
"skill": verdict.skill,
|
|
96
|
+
"skillId": str(verdict.skill_id),
|
|
97
|
+
"scanId": str(verdict.scan_id),
|
|
98
|
+
"engineVersion": ENGINE_VERSION,
|
|
99
|
+
"generatedAt": format_utc(generated_at or utc_now()),
|
|
100
|
+
}
|
|
101
|
+
if verdict.active_defense is not None:
|
|
102
|
+
run_properties["activeDefense"] = verdict.active_defense.model_dump(
|
|
103
|
+
by_alias=True, mode="json", exclude_none=False
|
|
104
|
+
)
|
|
105
|
+
if decision is not None:
|
|
106
|
+
# Report-safe summary only; see skill_sentry_cli.core.decisions.report.
|
|
107
|
+
run_properties["decisionLayer"] = decision
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
"$schema": SARIF_SCHEMA,
|
|
111
|
+
"version": "2.1.0",
|
|
112
|
+
"runs": [
|
|
113
|
+
{
|
|
114
|
+
"tool": {
|
|
115
|
+
"driver": {
|
|
116
|
+
"name": "SkillSentry",
|
|
117
|
+
"informationUri": "https://github.com/skillsentry/skillsentry",
|
|
118
|
+
"version": ENGINE_VERSION,
|
|
119
|
+
"semanticVersion": ENGINE_VERSION,
|
|
120
|
+
"rules": list(rules.values()),
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"automationDetails": {"id": f"skillsentry/{verdict.scan_id}"},
|
|
124
|
+
"invocations": [{"executionSuccessful": True}],
|
|
125
|
+
"results": results,
|
|
126
|
+
"properties": run_properties,
|
|
127
|
+
"columnKind": "utf16CodeUnits",
|
|
128
|
+
}
|
|
129
|
+
],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_sarif_text(verdict: Verdict) -> str:
|
|
134
|
+
import json
|
|
135
|
+
|
|
136
|
+
return json.dumps(build_sarif(verdict, generated_at=utc_now()), indent=2) + "\n"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
__all__ = ["LEVEL_BY_SEVERITY", "SARIF_SCHEMA", "build_sarif", "build_sarif_text", "rule_id"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""What a scan's result says about completeness, shared by every renderer.
|
|
2
|
+
|
|
3
|
+
A verdict is only as complete as its stages. When a stage degraded, the scan did
|
|
4
|
+
not read everything it should have, so a SAFE or SUSPICIOUS result is shown as
|
|
5
|
+
``UNSCANNED`` with the reason. A MALICIOUS verdict is conclusive evidence and is
|
|
6
|
+
never downgraded to "unscanned" by another stage's failure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from skill_sentry_cli.core.contracts import StageResult, VerdictTier
|
|
14
|
+
|
|
15
|
+
UNSCANNED = "UNSCANNED"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_unscanned(verdict: VerdictTier, stage_results: Sequence[StageResult] | None) -> bool:
|
|
19
|
+
if verdict == VerdictTier.MALICIOUS:
|
|
20
|
+
return False
|
|
21
|
+
return any(result.degraded for result in stage_results or ())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def degraded_reason(stage_results: Sequence[StageResult] | None) -> str:
|
|
25
|
+
for result in stage_results or ():
|
|
26
|
+
if result.degraded:
|
|
27
|
+
return f"{result.stage.value} degraded: {result.error or 'not fully analysed'}"
|
|
28
|
+
return "not fully analysed"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def shown_status(verdict: VerdictTier, stage_results: Sequence[StageResult] | None) -> str:
|
|
32
|
+
return UNSCANNED if is_unscanned(verdict, stage_results) else verdict.value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["UNSCANNED", "degraded_reason", "is_unscanned", "shown_status"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""STIX 2.1 IOC bundle writer (docs/05-api-reference.md §5.7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from skill_sentry_cli.core.contracts import Verdict, format_utc, utc_now
|
|
12
|
+
from skill_sentry_cli.core.version import ENGINE_VERSION
|
|
13
|
+
|
|
14
|
+
_IPV4_RE = re.compile(r"^(?:\d{1,3}\.){3}\d{1,3}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NoIOCsError(RuntimeError):
|
|
18
|
+
"""Raised when a STIX export is requested for a scan without IOCs."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _pattern_for(value: str) -> tuple[str, str]:
|
|
22
|
+
if value.startswith(("http://", "https://")):
|
|
23
|
+
return "url", f"[url:value = '{value}']"
|
|
24
|
+
if _IPV4_RE.match(value):
|
|
25
|
+
return "ipv4-addr", f"[ipv4-addr:value = '{value}']"
|
|
26
|
+
return "domain-name", f"[domain-name:value = '{value}']"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sdo(object_type: str, **fields: Any) -> dict[str, Any]:
|
|
30
|
+
return {"type": object_type, "spec_version": "2.1", "id": f"{object_type}--{uuid4()}", **fields}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_stix(verdict: Verdict, *, generated_at: datetime | None = None) -> dict[str, Any]:
|
|
34
|
+
"""Build a STIX 2.1 bundle, or raise when active defense did not trigger."""
|
|
35
|
+
active = verdict.active_defense
|
|
36
|
+
if active is None or not active.triggered:
|
|
37
|
+
raise NoIOCsError("no_iocs")
|
|
38
|
+
indicators = [value for value in active.c2_indicators if value]
|
|
39
|
+
if not indicators:
|
|
40
|
+
raise NoIOCsError("no_iocs")
|
|
41
|
+
|
|
42
|
+
created = generated_at or utc_now()
|
|
43
|
+
stamp = format_utc(created)
|
|
44
|
+
identity_id = f"identity--{uuid4()}"
|
|
45
|
+
malware_id = f"malware--{uuid4()}"
|
|
46
|
+
|
|
47
|
+
objects: list[dict[str, Any]] = [
|
|
48
|
+
_sdo(
|
|
49
|
+
"identity",
|
|
50
|
+
created=stamp,
|
|
51
|
+
modified=stamp,
|
|
52
|
+
name="SkillSentry 2.0",
|
|
53
|
+
description="Local-first Agent Skill supply-chain auditor.",
|
|
54
|
+
identity_class="system",
|
|
55
|
+
x_skillsentry_engine_version=ENGINE_VERSION,
|
|
56
|
+
),
|
|
57
|
+
_sdo(
|
|
58
|
+
"malware",
|
|
59
|
+
created=stamp,
|
|
60
|
+
modified=stamp,
|
|
61
|
+
created_by_ref=identity_id,
|
|
62
|
+
name=verdict.skill,
|
|
63
|
+
description=(
|
|
64
|
+
f"Agent Skill flagged {verdict.verdict.value} with risk "
|
|
65
|
+
f"{verdict.risk_score}/100. {active.actual_behavior or ''}".strip()
|
|
66
|
+
),
|
|
67
|
+
is_family=False,
|
|
68
|
+
labels=["malicious-activity"],
|
|
69
|
+
confidence=max(0, min(100, round(verdict.confidence * 100))),
|
|
70
|
+
x_skillsentry_scan_id=str(verdict.scan_id),
|
|
71
|
+
x_skillsentry_skill_id=str(verdict.skill_id),
|
|
72
|
+
x_skillsentry_canary_marker=active.canary_marker,
|
|
73
|
+
x_skillsentry_exfil_channel=(
|
|
74
|
+
active.exfil_channel.value if active.exfil_channel else None
|
|
75
|
+
),
|
|
76
|
+
x_skillsentry_encoding=active.encoding,
|
|
77
|
+
),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for value in indicators:
|
|
81
|
+
ioc_type, pattern = _pattern_for(value)
|
|
82
|
+
indicator_id = f"indicator--{uuid4()}"
|
|
83
|
+
objects.append(
|
|
84
|
+
_sdo(
|
|
85
|
+
"indicator",
|
|
86
|
+
created=stamp,
|
|
87
|
+
modified=stamp,
|
|
88
|
+
created_by_ref=identity_id,
|
|
89
|
+
name=f"C2 {ioc_type} {value}",
|
|
90
|
+
description=(
|
|
91
|
+
f"Observed in sandboxed {active.exfil_channel.value if active.exfil_channel else 'egress'} "
|
|
92
|
+
f"traffic while serving canary {active.canary_marker or 'material'}."
|
|
93
|
+
),
|
|
94
|
+
indicator_types=["malicious-activity"],
|
|
95
|
+
pattern=pattern,
|
|
96
|
+
pattern_type="stix",
|
|
97
|
+
valid_from=stamp,
|
|
98
|
+
labels=["malicious-activity"],
|
|
99
|
+
confidence=100 if active.triggered else 50,
|
|
100
|
+
x_skillsentry_channel=(
|
|
101
|
+
active.exfil_channel.value if active.exfil_channel else None
|
|
102
|
+
),
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
objects.append(
|
|
106
|
+
_sdo(
|
|
107
|
+
"relationship",
|
|
108
|
+
created=stamp,
|
|
109
|
+
modified=stamp,
|
|
110
|
+
created_by_ref=identity_id,
|
|
111
|
+
relationship_type="indicates",
|
|
112
|
+
source_ref=malware_id,
|
|
113
|
+
target_ref=indicator_id,
|
|
114
|
+
description=f"{verdict.skill} exfiltrates to {value}",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"type": "bundle",
|
|
120
|
+
"id": f"bundle--{uuid4()}",
|
|
121
|
+
"spec_version": "2.1",
|
|
122
|
+
"objects": objects,
|
|
123
|
+
"x_skillsentry_verdict": verdict.verdict.value,
|
|
124
|
+
"x_skillsentry_risk_score": verdict.risk_score,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_stix_text(verdict: Verdict, *, generated_at: datetime | None = None) -> str:
|
|
129
|
+
return json.dumps(build_stix(verdict, generated_at=generated_at), indent=2) + "\n"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ["NoIOCsError", "build_stix", "build_stix_text"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Package metadata and engine version helpers.
|
|
2
|
+
|
|
3
|
+
The engine version participates in the Stage-0 cache key. Any change to the
|
|
4
|
+
fusion weights is folded into the version string so cached verdicts produced
|
|
5
|
+
under different scoring rules are never reused (docs/03-database.md §3.5).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
__version__ = "2.0.0"
|
|
15
|
+
ENGINE_VERSION = __version__
|
|
16
|
+
CONTRACT_VERSION = "1"
|
|
17
|
+
GEMMA_BADGE = "Fully local analysis · open weights"
|
|
18
|
+
|
|
19
|
+
DEFAULT_WEIGHTS: dict[str, float] = {
|
|
20
|
+
"anchors": 0.15,
|
|
21
|
+
"semantic": 0.20,
|
|
22
|
+
"vision": 0.20,
|
|
23
|
+
"sandbox": 0.25,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def weights_fingerprint(weights: Mapping[str, float] | None = None) -> str:
|
|
28
|
+
"""Return a stable short fingerprint for a fusion-weight configuration."""
|
|
29
|
+
payload = json.dumps(dict(weights or DEFAULT_WEIGHTS), sort_keys=True, separators=(",", ":"))
|
|
30
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:8]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def engine_version(weights: Mapping[str, float] | None = None) -> str:
|
|
34
|
+
"""Engine version including the scoring-weight fingerprint.
|
|
35
|
+
|
|
36
|
+
Example: ``2.0.0+a1b2c3d4``.
|
|
37
|
+
"""
|
|
38
|
+
return f"{ENGINE_VERSION}+{weights_fingerprint(weights)}"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Bundled image extraction and vision analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from skill_sentry_cli.core.vision.image_extractor import ImageAsset, extract_images
|
|
6
|
+
from skill_sentry_cli.core.vision.vision_scanner import scan_vision
|
|
7
|
+
|
|
8
|
+
__all__ = ["ImageAsset", "extract_images", "scan_vision"]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Bounded image discovery and safe preview construction for vision analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import mimetypes
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from skill_sentry_cli.core.parser import SkillPackage
|
|
11
|
+
|
|
12
|
+
IMAGE_SUFFIXES = frozenset(
|
|
13
|
+
{".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".svg"}
|
|
14
|
+
)
|
|
15
|
+
MAX_IMAGES = 20
|
|
16
|
+
MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
17
|
+
MAX_PREVIEW_BYTES = 2 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ImageAsset:
|
|
22
|
+
path: Path
|
|
23
|
+
relative: str
|
|
24
|
+
suffix: str
|
|
25
|
+
size: int
|
|
26
|
+
data: bytes
|
|
27
|
+
mime: str
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_svg(self) -> bool:
|
|
31
|
+
return self.suffix == ".svg"
|
|
32
|
+
|
|
33
|
+
def preview_uri(self) -> str:
|
|
34
|
+
payload = self.data
|
|
35
|
+
if len(payload) > MAX_PREVIEW_BYTES:
|
|
36
|
+
# The API schema needs a displayable string. Do not inline
|
|
37
|
+
# arbitrarily large blobs into a verdict; give the UI a stable
|
|
38
|
+
# relative reference instead.
|
|
39
|
+
return f"skill-file:{self.relative}"
|
|
40
|
+
encoded = base64.b64encode(payload).decode("ascii")
|
|
41
|
+
return f"data:{self.mime};base64,{encoded}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def extract_images(package: SkillPackage) -> tuple[list[ImageAsset], list[str]]:
|
|
45
|
+
"""Return image assets (bounded) and human-readable skip notes."""
|
|
46
|
+
|
|
47
|
+
assets: list[ImageAsset] = []
|
|
48
|
+
notes: list[str] = []
|
|
49
|
+
for path in package.files:
|
|
50
|
+
if len(assets) >= MAX_IMAGES:
|
|
51
|
+
notes.append(f"image limit reached ({MAX_IMAGES}); remaining images not scanned")
|
|
52
|
+
break
|
|
53
|
+
suffix = path.suffix.lower()
|
|
54
|
+
if suffix not in IMAGE_SUFFIXES:
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
size = path.stat().st_size
|
|
58
|
+
except OSError as exc:
|
|
59
|
+
notes.append(f"could not stat {path.name}: {exc}")
|
|
60
|
+
continue
|
|
61
|
+
if size > MAX_IMAGE_BYTES:
|
|
62
|
+
notes.append(f"{path.name} exceeds 20 MiB and was not scanned")
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
data = path.read_bytes()
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
notes.append(f"could not read {path.name}: {exc}")
|
|
68
|
+
continue
|
|
69
|
+
mime = mimetypes.guess_type(path.name)[0] or (
|
|
70
|
+
"image/svg+xml" if suffix == ".svg" else "application/octet-stream"
|
|
71
|
+
)
|
|
72
|
+
assets.append(
|
|
73
|
+
ImageAsset(
|
|
74
|
+
path=path,
|
|
75
|
+
relative=path.relative_to(package.root).as_posix(),
|
|
76
|
+
suffix=suffix,
|
|
77
|
+
size=size,
|
|
78
|
+
data=data,
|
|
79
|
+
mime=mime,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return assets, notes
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
__all__ = [
|
|
86
|
+
"IMAGE_SUFFIXES",
|
|
87
|
+
"MAX_IMAGES",
|
|
88
|
+
"MAX_IMAGE_BYTES",
|
|
89
|
+
"ImageAsset",
|
|
90
|
+
"extract_images",
|
|
91
|
+
]
|