skillguard-core 0.1.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.
- skillguard_core/__init__.py +1 -0
- skillguard_core/cli.py +170 -0
- skillguard_core/config.py +28 -0
- skillguard_core/engines/__init__.py +0 -0
- skillguard_core/engines/base.py +54 -0
- skillguard_core/engines/cisco.py +68 -0
- skillguard_core/engines/fusion.py +49 -0
- skillguard_core/engines/skillspector.py +75 -0
- skillguard_core/ingest/__init__.py +0 -0
- skillguard_core/ingest/fetcher.py +151 -0
- skillguard_core/pipeline/__init__.py +0 -0
- skillguard_core/pipeline/scan.py +151 -0
- skillguard_core/sarif.py +50 -0
- skillguard_core/semantic/__init__.py +0 -0
- skillguard_core/semantic/reviewer.py +137 -0
- skillguard_core-0.1.2.dist-info/METADATA +179 -0
- skillguard_core-0.1.2.dist-info/RECORD +21 -0
- skillguard_core-0.1.2.dist-info/WHEEL +4 -0
- skillguard_core-0.1.2.dist-info/entry_points.txt +2 -0
- skillguard_core-0.1.2.dist-info/licenses/LICENSE +202 -0
- skillguard_core-0.1.2.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
skillguard_core/cli.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import tqdm
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from skillguard_core.config import get_settings
|
|
13
|
+
from skillguard_core.engines.cisco import CiscoScannerEngine
|
|
14
|
+
from skillguard_core.engines.skillspector import SkillspectorEngine
|
|
15
|
+
from skillguard_core.ingest.fetcher import discover_skills
|
|
16
|
+
from skillguard_core.pipeline.scan import ScanReport, ScanService
|
|
17
|
+
from skillguard_core.sarif import to_sarif, to_sarif_batch
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(no_args_is_help=True)
|
|
20
|
+
|
|
21
|
+
EXIT_CODES = {"safe": 0, "caution": 1, "dangerous": 2, "inconclusive": 3}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command(hidden=True)
|
|
25
|
+
def _version():
|
|
26
|
+
"""Show version."""
|
|
27
|
+
from skillguard_core import __version__
|
|
28
|
+
|
|
29
|
+
typer.echo(f"skillguard-core {__version__}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _engines() -> list:
|
|
33
|
+
settings = get_settings()
|
|
34
|
+
return [
|
|
35
|
+
SkillspectorEngine(binary=settings.skillspector_bin, timeout_s=settings.scan_timeout_s),
|
|
36
|
+
CiscoScannerEngine(binary=settings.cisco_bin, policy=settings.cisco_policy, timeout_s=settings.scan_timeout_s),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_skills_dir(target: str) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
path = Path(target).expanduser().resolve()
|
|
43
|
+
return path.is_dir() and not (path / "SKILL.md").exists() and any(
|
|
44
|
+
d.is_dir() and (d / "SKILL.md").exists() for d in path.iterdir()
|
|
45
|
+
)
|
|
46
|
+
except OSError:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format_report(report, json_output: bool, sarif: bool):
|
|
51
|
+
if sarif:
|
|
52
|
+
return json.dumps(to_sarif(report), indent=2)
|
|
53
|
+
if json_output:
|
|
54
|
+
return json.dumps(asdict(report), indent=2)
|
|
55
|
+
lines = [f"{report.skill_name}: {report.verdict.upper()} (score {report.fused_score})"]
|
|
56
|
+
if report.llm_reviewed:
|
|
57
|
+
lines.append(f" [llm] verdict '{report.llm_verdict}', confidence {report.llm_confidence:.0%}: {report.llm_rationale}")
|
|
58
|
+
elif report.llm_skipped_reason:
|
|
59
|
+
lines.append(f" [info] --use-llm skipped: {report.llm_skipped_reason}")
|
|
60
|
+
for f in report.findings:
|
|
61
|
+
lines.append(f" [{f.severity}] {f.engine}/{f.rule_id}: {f.title} ({f.file_path})")
|
|
62
|
+
return "\n".join(lines)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def scan(
|
|
67
|
+
target: str = typer.Argument(...),
|
|
68
|
+
use_llm: bool = False,
|
|
69
|
+
json_output: bool = typer.Option(False, "--json"),
|
|
70
|
+
sarif: bool = typer.Option(False, "--sarif"),
|
|
71
|
+
verbose: bool = typer.Option(False, "--verbose", "-v", help="Stream results as each skill is scanned"),
|
|
72
|
+
workers: int = typer.Option(os.cpu_count() or 1, "--workers", "-w", help="Parallel scan workers (default: CPU count)"),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Scan a skill directory, git URL, or zip URL."""
|
|
75
|
+
if json_output and sarif:
|
|
76
|
+
typer.echo("error: --json and --sarif are mutually exclusive", err=True)
|
|
77
|
+
raise typer.Exit(3)
|
|
78
|
+
service = ScanService(engines=_engines(), reviewer=_try_get_reviewer(use_llm))
|
|
79
|
+
|
|
80
|
+
if _is_skills_dir(target):
|
|
81
|
+
if json_output or sarif:
|
|
82
|
+
try:
|
|
83
|
+
reports = service.scan_directory(target, use_llm=use_llm, max_workers=workers)
|
|
84
|
+
except Exception as exc: # noqa: BLE001
|
|
85
|
+
typer.echo(f"error: {exc}", err=True)
|
|
86
|
+
raise typer.Exit(3)
|
|
87
|
+
if json_output:
|
|
88
|
+
typer.echo(json.dumps([asdict(r) for r in reports], indent=2))
|
|
89
|
+
else:
|
|
90
|
+
typer.echo(json.dumps(to_sarif_batch(reports), indent=2))
|
|
91
|
+
raise typer.Exit(max(EXIT_CODES.get(r.verdict, 0) for r in reports))
|
|
92
|
+
|
|
93
|
+
skill_dirs = discover_skills(Path(target))
|
|
94
|
+
results: dict[int, ScanReport] = {}
|
|
95
|
+
with tqdm.tqdm(total=len(skill_dirs), unit="skill", file=sys.stderr) as pbar, \
|
|
96
|
+
ThreadPoolExecutor(max_workers=workers) as pool:
|
|
97
|
+
futures = {pool.submit(service.scan_target, str(d.path), use_llm=use_llm): i for i, d in enumerate(skill_dirs)}
|
|
98
|
+
for future in as_completed(futures):
|
|
99
|
+
idx = futures[future]
|
|
100
|
+
report = _safe_scan(future, skill_dirs[idx])
|
|
101
|
+
results[idx] = report
|
|
102
|
+
tag = _verdict_tag(report.verdict)
|
|
103
|
+
pbar.set_description(f"{tag} {report.skill_name}")
|
|
104
|
+
if verbose:
|
|
105
|
+
tqdm.tqdm.write(_format_report(report, False, False), file=sys.stderr)
|
|
106
|
+
tqdm.tqdm.write("", file=sys.stderr)
|
|
107
|
+
pbar.update(1)
|
|
108
|
+
reports = [results[i] for i in range(len(skill_dirs))]
|
|
109
|
+
_print_summary(reports)
|
|
110
|
+
raise typer.Exit(max(EXIT_CODES.get(r.verdict, 0) for r in reports))
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
report = service.scan_target(target, use_llm=use_llm)
|
|
114
|
+
except Exception as exc: # noqa: BLE001
|
|
115
|
+
typer.echo(f"error: {exc}", err=True)
|
|
116
|
+
raise typer.Exit(3)
|
|
117
|
+
typer.echo(_format_report(report, json_output, sarif))
|
|
118
|
+
raise typer.Exit(EXIT_CODES.get(report.verdict, 3))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _safe_scan(future, d) -> ScanReport:
|
|
122
|
+
try:
|
|
123
|
+
return future.result()
|
|
124
|
+
except Exception as exc: # noqa: BLE001
|
|
125
|
+
return ScanReport(
|
|
126
|
+
skill_name=d.name, origin="local", source_url="", version_ref="",
|
|
127
|
+
content_hash="", engines=[], fused_score=0, severity="unknown",
|
|
128
|
+
verdict="inconclusive", llm_skipped_reason=str(exc),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _verdict_tag(verdict: str) -> str:
|
|
133
|
+
tags = {"dangerous": "⚠ ", "caution": "⚡", "safe": "✓ ", "inconclusive": "? "}
|
|
134
|
+
return tags.get(verdict, " ")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _get_reviewer():
|
|
138
|
+
from skillguard_core.semantic.reviewer import build_reviewer
|
|
139
|
+
|
|
140
|
+
return build_reviewer()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _try_get_reviewer(use_llm: bool):
|
|
144
|
+
if not use_llm:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
return _get_reviewer()
|
|
148
|
+
except ImportError:
|
|
149
|
+
typer.echo(
|
|
150
|
+
"error: --use-llm requires the 'ai' extra. Install with: pipx install skillguard-core[ai]",
|
|
151
|
+
err=True,
|
|
152
|
+
)
|
|
153
|
+
raise typer.Exit(3)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _print_summary(reports: list[ScanReport]):
|
|
157
|
+
verdict_colors = {"dangerous": typer.colors.RED, "caution": typer.colors.YELLOW, "safe": typer.colors.GREEN}
|
|
158
|
+
verdicts = Counter(r.verdict for r in reports)
|
|
159
|
+
total = len(reports)
|
|
160
|
+
typer.echo()
|
|
161
|
+
for verdict in ("dangerous", "caution", "safe"):
|
|
162
|
+
count = verdicts.get(verdict, 0)
|
|
163
|
+
if count:
|
|
164
|
+
color = verdict_colors.get(verdict)
|
|
165
|
+
typer.secho(f" {verdict.upper():<12} {count:>3}/{total}", fg=color)
|
|
166
|
+
typer.echo(f" {'total':<12} {total:>3}")
|
|
167
|
+
typer.echo()
|
|
168
|
+
for r in sorted(reports, key=lambda r: (EXIT_CODES.get(r.verdict, 99), r.skill_name)):
|
|
169
|
+
tag = _verdict_tag(r.verdict)
|
|
170
|
+
typer.secho(f" {tag} {r.skill_name} (score {r.fused_score})", fg=verdict_colors.get(r.verdict))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
load_dotenv()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
model_config = SettingsConfigDict(env_prefix="SKILLGUARD_", env_file=".env", extra="ignore")
|
|
11
|
+
|
|
12
|
+
ingest_max_bytes: int = 100 * 1024 * 1024
|
|
13
|
+
ingest_max_zip_members: int = 10_000
|
|
14
|
+
scan_timeout_s: int = 300
|
|
15
|
+
skillspector_bin: str = "skillspector"
|
|
16
|
+
cisco_bin: str = "skill-scanner"
|
|
17
|
+
cisco_policy: str = "balanced"
|
|
18
|
+
semantic_model: str = "claude-sonnet-4-5"
|
|
19
|
+
anthropic_api_key: str = ""
|
|
20
|
+
llm_api_key: str = ""
|
|
21
|
+
llm_base_url: str = ""
|
|
22
|
+
danger_min: int = 70
|
|
23
|
+
caution_min: int = 30
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@lru_cache
|
|
27
|
+
def get_settings() -> Settings:
|
|
28
|
+
return Settings()
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import subprocess
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
SEVERITIES = ("low", "medium", "high", "critical")
|
|
7
|
+
SEVERITY_ALIASES = {
|
|
8
|
+
"info": "low",
|
|
9
|
+
"minor": "low",
|
|
10
|
+
"warning": "medium",
|
|
11
|
+
"severe": "high",
|
|
12
|
+
"crit": "critical",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Runner = Callable[..., subprocess.CompletedProcess]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def normalize_severity(value: str | None) -> str:
|
|
19
|
+
v = (value or "").strip().lower()
|
|
20
|
+
v = SEVERITY_ALIASES.get(v, v)
|
|
21
|
+
return v if v in SEVERITIES else "medium"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def truncate_evidence(text: str | None, limit: int = 400) -> str:
|
|
25
|
+
if not text:
|
|
26
|
+
return ""
|
|
27
|
+
text = text.strip()
|
|
28
|
+
return text[:limit] + ("…" if len(text) > limit else "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def make_fingerprint(engine: str, rule_id: str, file_path: str, title: str) -> str:
|
|
32
|
+
raw = f"{engine}|{rule_id}|{file_path}|{title}"
|
|
33
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class EngineFinding:
|
|
38
|
+
engine: str
|
|
39
|
+
rule_id: str
|
|
40
|
+
category: str
|
|
41
|
+
title: str
|
|
42
|
+
severity: str
|
|
43
|
+
file_path: str = ""
|
|
44
|
+
evidence: str = ""
|
|
45
|
+
fingerprint: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class EngineResult:
|
|
50
|
+
engine: str
|
|
51
|
+
score: int | None = None
|
|
52
|
+
findings: list[EngineFinding] = field(default_factory=list)
|
|
53
|
+
error: str | None = None
|
|
54
|
+
duration_ms: int = 0
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from skillguard_core.engines.base import (
|
|
7
|
+
EngineFinding,
|
|
8
|
+
EngineResult,
|
|
9
|
+
Runner,
|
|
10
|
+
make_fingerprint,
|
|
11
|
+
normalize_severity,
|
|
12
|
+
truncate_evidence,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CiscoScannerEngine:
|
|
17
|
+
name = "cisco"
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
binary: str = "skill-scanner",
|
|
22
|
+
policy: str = "balanced",
|
|
23
|
+
timeout_s: int = 300,
|
|
24
|
+
runner: Runner = subprocess.run,
|
|
25
|
+
):
|
|
26
|
+
self.binary = binary
|
|
27
|
+
self.policy = policy
|
|
28
|
+
self.timeout_s = timeout_s
|
|
29
|
+
self.runner = runner
|
|
30
|
+
|
|
31
|
+
def scan(self, path: Path) -> EngineResult:
|
|
32
|
+
started = time.monotonic()
|
|
33
|
+
cmd = [self.binary, "scan", str(path), "--policy", self.policy, "--format", "json"]
|
|
34
|
+
try:
|
|
35
|
+
proc = self.runner(cmd, capture_output=True, text=True, timeout=self.timeout_s)
|
|
36
|
+
except subprocess.TimeoutExpired:
|
|
37
|
+
return EngineResult(engine=self.name, error=f"timeout after {self.timeout_s}s", duration_ms=self._ms(started))
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
return EngineResult(engine=self.name, error=f"runner failed: {exc}", duration_ms=self._ms(started))
|
|
40
|
+
stdout = (proc.stdout or "").strip()
|
|
41
|
+
if not stdout:
|
|
42
|
+
tail = (proc.stderr or "").strip()[-2000:]
|
|
43
|
+
return EngineResult(engine=self.name, error=f"no json output: {tail}", duration_ms=self._ms(started))
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(stdout)
|
|
46
|
+
except json.JSONDecodeError as exc:
|
|
47
|
+
return EngineResult(engine=self.name, error=f"invalid json: {exc}", duration_ms=self._ms(started))
|
|
48
|
+
return self._parse(data, started)
|
|
49
|
+
|
|
50
|
+
def _parse(self, data: dict, started: float) -> EngineResult:
|
|
51
|
+
findings: list[EngineFinding] = []
|
|
52
|
+
for f in data.get("findings") or []:
|
|
53
|
+
finding = EngineFinding(
|
|
54
|
+
engine=self.name,
|
|
55
|
+
rule_id=str(f.get("id") or f.get("rule_id") or ""),
|
|
56
|
+
category=str(f.get("category") or ""),
|
|
57
|
+
title=str(f.get("title") or f.get("description") or ""),
|
|
58
|
+
severity=normalize_severity(f.get("severity")),
|
|
59
|
+
file_path=str(f.get("path") or f.get("file") or ""),
|
|
60
|
+
evidence=truncate_evidence(f.get("snippet") or f.get("evidence")),
|
|
61
|
+
)
|
|
62
|
+
finding.fingerprint = make_fingerprint(self.name, finding.rule_id, finding.file_path, finding.title)
|
|
63
|
+
findings.append(finding)
|
|
64
|
+
return EngineResult(engine=self.name, score=None, findings=findings, duration_ms=self._ms(started))
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _ms(started: float) -> int:
|
|
68
|
+
return int((time.monotonic() - started) * 1000)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from skillguard_core.engines.base import EngineResult
|
|
4
|
+
|
|
5
|
+
SEVERITY_SCORES = {"critical": 95, "high": 80, "medium": 50, "low": 20}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class FusedVerdict:
|
|
10
|
+
score: int
|
|
11
|
+
severity: str
|
|
12
|
+
verdict: str
|
|
13
|
+
engine_errors: dict[str, str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def engine_score(result: EngineResult) -> int:
|
|
17
|
+
score = 0
|
|
18
|
+
if result.score is not None:
|
|
19
|
+
score = max(0, min(100, result.score))
|
|
20
|
+
if result.findings:
|
|
21
|
+
score = max(score, *(SEVERITY_SCORES.get(f.severity, 0) for f in result.findings))
|
|
22
|
+
return score
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def score_to_severity(score: int) -> str:
|
|
26
|
+
if score >= 90:
|
|
27
|
+
return "critical"
|
|
28
|
+
if score >= 70:
|
|
29
|
+
return "high"
|
|
30
|
+
if score >= 30:
|
|
31
|
+
return "medium"
|
|
32
|
+
if score > 0:
|
|
33
|
+
return "low"
|
|
34
|
+
return "none"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fuse(results: list[EngineResult], danger_min: int = 70, caution_min: int = 30) -> FusedVerdict:
|
|
38
|
+
errors = {r.engine: r.error for r in results if r.error}
|
|
39
|
+
valid = [r for r in results if r.error is None]
|
|
40
|
+
if not valid:
|
|
41
|
+
return FusedVerdict(score=0, severity="unknown", verdict="inconclusive", engine_errors=errors)
|
|
42
|
+
score = max(engine_score(r) for r in valid)
|
|
43
|
+
if score >= danger_min:
|
|
44
|
+
verdict = "dangerous"
|
|
45
|
+
elif score >= caution_min:
|
|
46
|
+
verdict = "caution"
|
|
47
|
+
else:
|
|
48
|
+
verdict = "safe"
|
|
49
|
+
return FusedVerdict(score=score, severity=score_to_severity(score), verdict=verdict, engine_errors=errors)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from skillguard_core.engines.base import (
|
|
8
|
+
EngineFinding,
|
|
9
|
+
EngineResult,
|
|
10
|
+
Runner,
|
|
11
|
+
make_fingerprint,
|
|
12
|
+
normalize_severity,
|
|
13
|
+
truncate_evidence,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SkillspectorEngine:
|
|
18
|
+
name = "skillspector"
|
|
19
|
+
|
|
20
|
+
def __init__(self, binary: str = "skillspector", timeout_s: int = 300, runner: Runner = subprocess.run):
|
|
21
|
+
self.binary = binary
|
|
22
|
+
self.timeout_s = timeout_s
|
|
23
|
+
self.runner = runner
|
|
24
|
+
|
|
25
|
+
def scan(self, path: Path) -> EngineResult:
|
|
26
|
+
started = time.monotonic()
|
|
27
|
+
with tempfile.TemporaryDirectory() as td:
|
|
28
|
+
report_path = Path(td) / "report.json"
|
|
29
|
+
cmd = [
|
|
30
|
+
self.binary, "scan", str(path),
|
|
31
|
+
"--no-llm", "--format", "json", "--output", str(report_path),
|
|
32
|
+
]
|
|
33
|
+
try:
|
|
34
|
+
proc = self.runner(cmd, capture_output=True, text=True, timeout=self.timeout_s)
|
|
35
|
+
except subprocess.TimeoutExpired:
|
|
36
|
+
return EngineResult(engine=self.name, error=f"timeout after {self.timeout_s}s", duration_ms=self._ms(started))
|
|
37
|
+
except OSError as exc:
|
|
38
|
+
return EngineResult(engine=self.name, error=f"runner failed: {exc}", duration_ms=self._ms(started))
|
|
39
|
+
if not report_path.exists():
|
|
40
|
+
tail = (proc.stderr or proc.stdout or "").strip()[-2000:]
|
|
41
|
+
return EngineResult(engine=self.name, error=f"no report produced: {tail}", duration_ms=self._ms(started))
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(report_path.read_text())
|
|
44
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
45
|
+
return EngineResult(engine=self.name, error=f"cannot read report: {exc}", duration_ms=self._ms(started))
|
|
46
|
+
return self._parse(data, started)
|
|
47
|
+
|
|
48
|
+
def _parse(self, data: dict, started: float) -> EngineResult:
|
|
49
|
+
findings: list[EngineFinding] = []
|
|
50
|
+
items = data.get("issues") or data.get("findings") or []
|
|
51
|
+
for f in items:
|
|
52
|
+
loc = f.get("location") or {}
|
|
53
|
+
finding = EngineFinding(
|
|
54
|
+
engine=self.name,
|
|
55
|
+
rule_id=str(f.get("rule_id") or f.get("id") or ""),
|
|
56
|
+
category=str(f.get("category") or ""),
|
|
57
|
+
title=str(f.get("title") or f.get("pattern") or f.get("finding") or f.get("description") or ""),
|
|
58
|
+
severity=normalize_severity(f.get("severity")),
|
|
59
|
+
file_path=str(f.get("file") or loc.get("file") or f.get("path") or ""),
|
|
60
|
+
evidence=truncate_evidence(f.get("snippet") or f.get("code_snippet") or f.get("evidence") or f.get("finding")),
|
|
61
|
+
)
|
|
62
|
+
finding.fingerprint = make_fingerprint(self.name, finding.rule_id, finding.file_path, finding.title)
|
|
63
|
+
findings.append(finding)
|
|
64
|
+
risk = data.get("risk_assessment") or {}
|
|
65
|
+
score = data.get("risk_score") or risk.get("score")
|
|
66
|
+
return EngineResult(
|
|
67
|
+
engine=self.name,
|
|
68
|
+
score=int(score) if score is not None else None,
|
|
69
|
+
findings=findings,
|
|
70
|
+
duration_ms=self._ms(started),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _ms(started: float) -> int:
|
|
75
|
+
return int((time.monotonic() - started) * 1000)
|
|
File without changes
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import io
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import zipfile
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IngestLimitExceeded(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class Fetched:
|
|
19
|
+
path: Path
|
|
20
|
+
origin: str
|
|
21
|
+
source_url: str
|
|
22
|
+
version_ref: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def content_hash(root: Path) -> str:
|
|
26
|
+
digest = hashlib.sha256()
|
|
27
|
+
for file_path in sorted(p for p in root.rglob("*") if p.is_file() and not p.is_symlink()):
|
|
28
|
+
rel = file_path.relative_to(root).as_posix()
|
|
29
|
+
file_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()
|
|
30
|
+
digest.update(f"{rel}:{file_hash}\n".encode())
|
|
31
|
+
return digest.hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch(
|
|
35
|
+
target: str,
|
|
36
|
+
*,
|
|
37
|
+
tmp_root: Path,
|
|
38
|
+
max_bytes: int,
|
|
39
|
+
max_zip_members: int = 10_000,
|
|
40
|
+
client: httpx.Client | None = None,
|
|
41
|
+
git_runner: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
|
42
|
+
timeout_s: int = 120,
|
|
43
|
+
) -> Fetched:
|
|
44
|
+
tmp_root.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
if "://" not in target and not target.endswith(".git"):
|
|
46
|
+
local = Path(target).expanduser().resolve()
|
|
47
|
+
if not local.is_dir():
|
|
48
|
+
raise FileNotFoundError(f"not a directory: {target}")
|
|
49
|
+
if not (local / "SKILL.md").exists():
|
|
50
|
+
raise FileNotFoundError(f"no SKILL.md found in '{target}'")
|
|
51
|
+
return Fetched(path=local, origin="local", source_url=str(local), version_ref="")
|
|
52
|
+
|
|
53
|
+
workdir = Path(tempfile.mkdtemp(dir=tmp_root))
|
|
54
|
+
if target.endswith(".zip") or "/zipball/" in target:
|
|
55
|
+
_download_zip(target, workdir, max_bytes, max_zip_members, client, timeout_s)
|
|
56
|
+
return Fetched(
|
|
57
|
+
path=_unwrap_single_dir(workdir), origin="zip", source_url=target, version_ref=""
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
version_ref = _git_clone(target, workdir, git_runner, timeout_s)
|
|
61
|
+
return Fetched(
|
|
62
|
+
path=workdir / "repo", origin="git", source_url=target, version_ref=version_ref
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def discover_skills(root: Path) -> list[Fetched]:
|
|
67
|
+
root = root.expanduser().resolve()
|
|
68
|
+
if not root.is_dir():
|
|
69
|
+
raise FileNotFoundError(f"not a directory: {root}")
|
|
70
|
+
subdirs = sorted(
|
|
71
|
+
d for d in root.iterdir() if d.is_dir() and (d / "SKILL.md").exists()
|
|
72
|
+
)
|
|
73
|
+
if (root / "SKILL.md").exists():
|
|
74
|
+
subdirs.insert(0, root)
|
|
75
|
+
if not subdirs:
|
|
76
|
+
raise FileNotFoundError(f"no SKILL.md found in '{root}'")
|
|
77
|
+
return [
|
|
78
|
+
Fetched(path=d, origin="local", source_url=str(d), version_ref="")
|
|
79
|
+
for d in subdirs
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _download_zip(
|
|
84
|
+
url: str,
|
|
85
|
+
dest: Path,
|
|
86
|
+
max_bytes: int,
|
|
87
|
+
max_zip_members: int,
|
|
88
|
+
client: httpx.Client | None,
|
|
89
|
+
timeout_s: int,
|
|
90
|
+
) -> None:
|
|
91
|
+
own_client = client is None
|
|
92
|
+
client = client or httpx.Client(follow_redirects=True, timeout=timeout_s)
|
|
93
|
+
try:
|
|
94
|
+
data = bytearray()
|
|
95
|
+
with client.stream("GET", url) as response:
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
for chunk in response.iter_bytes():
|
|
98
|
+
data.extend(chunk)
|
|
99
|
+
if len(data) > max_bytes:
|
|
100
|
+
raise IngestLimitExceeded(f"download exceeds {max_bytes} bytes")
|
|
101
|
+
buf = io.BytesIO(bytes(data))
|
|
102
|
+
with zipfile.ZipFile(buf) as zf:
|
|
103
|
+
members = zf.infolist()
|
|
104
|
+
if len(members) > max_zip_members:
|
|
105
|
+
raise IngestLimitExceeded(
|
|
106
|
+
f"zip has {len(members)} members (cap {max_zip_members})"
|
|
107
|
+
)
|
|
108
|
+
total = sum(m.file_size for m in members)
|
|
109
|
+
if total > max_bytes:
|
|
110
|
+
raise IngestLimitExceeded(
|
|
111
|
+
f"uncompressed size {total} exceeds {max_bytes} bytes"
|
|
112
|
+
)
|
|
113
|
+
for member in members:
|
|
114
|
+
member_path = Path(member.filename)
|
|
115
|
+
if member_path.is_absolute() or ".." in member_path.parts:
|
|
116
|
+
raise IngestLimitExceeded(f"unsafe zip entry: {member.filename}")
|
|
117
|
+
zf.extractall(dest)
|
|
118
|
+
finally:
|
|
119
|
+
if own_client:
|
|
120
|
+
client.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _git_clone(
|
|
124
|
+
url: str,
|
|
125
|
+
workdir: Path,
|
|
126
|
+
runner: Callable[..., subprocess.CompletedProcess],
|
|
127
|
+
timeout_s: int,
|
|
128
|
+
) -> str:
|
|
129
|
+
dest = workdir / "repo"
|
|
130
|
+
clone = runner(
|
|
131
|
+
["git", "clone", "--depth", "1", url, str(dest)],
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
timeout=timeout_s,
|
|
135
|
+
)
|
|
136
|
+
if clone.returncode != 0:
|
|
137
|
+
raise RuntimeError(f"git clone failed: {(clone.stderr or '').strip()[-2000:]}")
|
|
138
|
+
rev = runner(
|
|
139
|
+
["git", "-C", str(dest), "rev-parse", "HEAD"],
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
timeout=30,
|
|
143
|
+
)
|
|
144
|
+
return rev.stdout.strip()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _unwrap_single_dir(workdir: Path) -> Path:
|
|
148
|
+
entries = list(workdir.iterdir())
|
|
149
|
+
if len(entries) == 1 and entries[0].is_dir():
|
|
150
|
+
return entries[0]
|
|
151
|
+
return workdir
|
|
File without changes
|