lyrie-agent 0.3.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.
lyrie/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ """
2
+ Lyrie Agent — Python SDK.
3
+
4
+ Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License.
5
+
6
+ Embed the Shield Doctrine, Attack-Surface Mapper, Stages A–F validator,
7
+ multi-language scanners, threat-intel client, HTTP proxy, and EditEngine
8
+ in any Python project.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "SIGNATURE",
16
+ # Shield Doctrine
17
+ "Shield",
18
+ "ShieldVerdict",
19
+ # Attack-Surface Mapper
20
+ "AttackSurfaceMapper",
21
+ "AttackSurface",
22
+ "EntryPoint",
23
+ "TrustBoundary",
24
+ "DataFlow",
25
+ "RiskHotspot",
26
+ # Stages A-F validator
27
+ "StagesValidator",
28
+ "ValidatedFinding",
29
+ "StageVerdict",
30
+ "Finding",
31
+ # Multi-language scanners
32
+ "scan_files",
33
+ "ScanReport",
34
+ # HTTP proxy
35
+ "HttpProxy",
36
+ "HttpExchange",
37
+ "Mutator",
38
+ # EditEngine
39
+ "EditEngine",
40
+ "EditPlan",
41
+ # Threat-Intel
42
+ "ThreatIntelClient",
43
+ "ThreatAdvisory",
44
+ # OSS-Scan
45
+ "run_oss_scan",
46
+ "OssScanResult",
47
+ # LyrieEvolve
48
+ "LyrieEvolve",
49
+ "TaskOutcome",
50
+ "SkillContext",
51
+ "TrainingEntry",
52
+ "ExtractionResult",
53
+ ]
54
+
55
+ __version__ = "0.5.0"
56
+ SIGNATURE: str = "Lyrie.ai by OTT Cybersecurity LLC"
57
+
58
+ from lyrie.shield import Shield, ShieldVerdict
59
+ from lyrie.attack_surface import (
60
+ AttackSurfaceMapper,
61
+ AttackSurface,
62
+ EntryPoint,
63
+ TrustBoundary,
64
+ DataFlow,
65
+ RiskHotspot,
66
+ )
67
+ from lyrie.stages import (
68
+ StagesValidator,
69
+ ValidatedFinding,
70
+ StageVerdict,
71
+ Finding,
72
+ )
73
+ from lyrie.scanners import scan_files, ScanReport
74
+ from lyrie.proxy import HttpProxy, HttpExchange, Mutator
75
+ from lyrie.edits import EditEngine, EditPlan
76
+ from lyrie.threat_intel import ThreatIntelClient, ThreatAdvisory
77
+ from lyrie.oss_scan import run_oss_scan, OssScanResult
78
+ from lyrie.evolve import LyrieEvolve, TaskOutcome, SkillContext, TrainingEntry, ExtractionResult
@@ -0,0 +1,438 @@
1
+ """
2
+ Lyrie Attack-Surface Mapper — Python port.
3
+
4
+ Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License.
5
+
6
+ Builds a structural picture of what's worth attacking BEFORE any
7
+ vulnerability scanner runs. Pure static analysis; never executes code,
8
+ never opens a network socket. Every text it ingests passes the Shield.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import subprocess
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from typing import Final, Iterable, Literal, Optional, Pattern, Sequence
20
+
21
+ from lyrie.shield import Shield, ShieldVerdict
22
+
23
+ MAPPER_VERSION: Final[str] = "lyrie-asm-py-1.0.0"
24
+ SIGNATURE: Final[str] = "Lyrie.ai by OTT Cybersecurity LLC"
25
+
26
+ EntryKind = Literal[
27
+ "http-route", "cli-command", "file-reader", "env-consumer",
28
+ "deserialization-sink", "subprocess", "render", "websocket", "cron",
29
+ ]
30
+ BoundaryKind = Literal[
31
+ "auth-gate", "rbac-check", "rate-limit", "sandbox-cross",
32
+ "shell-exec", "network-egress", "shield-gate",
33
+ ]
34
+ FlowSource = Literal[
35
+ "http-input", "cli-arg", "env-var", "file-read", "network-fetch",
36
+ "user-message", "mcp-tool-result", "memory-recall",
37
+ ]
38
+ FlowSink = Literal[
39
+ "shell", "filesystem-write", "sql", "http-output",
40
+ "agent-prompt", "tool-call", "deserialization",
41
+ ]
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class EntryPoint:
46
+ kind: EntryKind
47
+ file: str
48
+ line: int
49
+ evidence: str
50
+ detail: dict[str, str] = field(default_factory=dict)
51
+
52
+
53
+ @dataclass(slots=True)
54
+ class TrustBoundary:
55
+ kind: BoundaryKind
56
+ file: str
57
+ line: int
58
+ evidence: str
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class DataFlow:
63
+ source: FlowSource
64
+ sink: FlowSink
65
+ file: str
66
+ line: int
67
+ evidence: str
68
+ risk: int
69
+
70
+
71
+ @dataclass(slots=True)
72
+ class DependencyEntry:
73
+ name: str
74
+ version: Optional[str] = None
75
+ manifest: str = ""
76
+ ecosystem: str = "unknown"
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class RiskHotspot:
81
+ file: str
82
+ score: int
83
+ reasons: list[str]
84
+
85
+
86
+ @dataclass(slots=True)
87
+ class AttackSurface:
88
+ root: str
89
+ files_inspected: int
90
+ files_ignored: int
91
+ files_shielded: int
92
+ entry_points: list[EntryPoint]
93
+ trust_boundaries: list[TrustBoundary]
94
+ data_flows: list[DataFlow]
95
+ dependencies: list[DependencyEntry]
96
+ hotspots: list[RiskHotspot]
97
+ generated_at: str
98
+ mapper_version: str = MAPPER_VERSION
99
+ signature: str = SIGNATURE
100
+
101
+
102
+ # ─── Detector regexes ───────────────────────────────────────────────────────
103
+
104
+ _ENTRY_DETECTORS: Final[Sequence[tuple[EntryKind, Pattern[str]]]] = (
105
+ ("http-route", re.compile(
106
+ r"\b(?:app|router|route|server|fastify|hono|elysia|express|koa)"
107
+ r"\.(get|post|put|patch|delete|all|use)\s*\(\s*[\"'`]([^\"'`]+)[\"'`]",
108
+ re.IGNORECASE,
109
+ )),
110
+ ("http-route", re.compile(r"@(?:Get|Post|Put|Patch|Delete|All)\s*\(\s*[\"'`]([^\"'`]+)[\"'`]")),
111
+ ("http-route", re.compile(r"@app\.route\(\s*[\"'`]([^\"'`]+)[\"'`]")),
112
+ ("http-route", re.compile(r"^\s*(?:async\s+)?def\s+\w+\s*\([^)]*request[^)]*\)", re.MULTILINE)),
113
+ ("cli-command", re.compile(r"\bprogram\.command\s*\(\s*[\"'`]([^\"'`]+)[\"'`]")),
114
+ ("cli-command", re.compile(r"\b@click\.command\(")),
115
+ ("cli-command", re.compile(r"\bcobra\.Command\s*\{")),
116
+ ("file-reader", re.compile(r"\b(readFileSync|readFile|fs\.readFile|os\.open|ioutil\.ReadFile)\b")),
117
+ ("file-reader", re.compile(r"\bopen\s*\([^)]*[\"'][^\"']+[\"']")),
118
+ ("env-consumer", re.compile(r"\bprocess\.env\.[A-Z_][A-Z0-9_]*")),
119
+ ("env-consumer", re.compile(r"\bos\.environ\b")),
120
+ ("env-consumer", re.compile(r"\bos\.getenv\s*\(\s*[\"'`]([A-Z_][A-Z0-9_]*)[\"'`]", re.IGNORECASE)),
121
+ ("deserialization-sink", re.compile(
122
+ r"\b(JSON\.parse|YAML\.parse|yaml\.load|pickle\.loads?|marshal\.loads?|deserialize|fromJSON)\b"
123
+ )),
124
+ ("subprocess", re.compile(
125
+ r"\b(exec|execSync|spawn|spawnSync|child_process|subprocess\.run|os\.system|exec\.Command)\b"
126
+ )),
127
+ ("render", re.compile(
128
+ r"\b(res\.render|renderTemplate|renderToString|render_template|template\.Execute)\b"
129
+ )),
130
+ ("websocket", re.compile(r"\b(new WebSocket|ws\.on\s*\(|wss\.on\s*\()")),
131
+ ("cron", re.compile(r"\b(cron\.schedule|new CronJob|@Cron|crontab)")),
132
+ )
133
+
134
+ _BOUNDARY_DETECTORS: Final[Sequence[tuple[BoundaryKind, Pattern[str]]]] = (
135
+ ("auth-gate", re.compile(
136
+ r"\b(authenticate|requireAuth|isAuthenticated|verifyToken|jwt\.verify|passport\.authenticate)\b"
137
+ )),
138
+ ("rbac-check", re.compile(r"\b(hasRole|requireRole|checkPermission|authorize|policy\.|rbac)\b", re.IGNORECASE)),
139
+ ("rate-limit", re.compile(r"\b(rateLimit|rate_limit|RateLimiter|throttle\(|express-rate-limit)\b", re.IGNORECASE)),
140
+ ("sandbox-cross", re.compile(r"\b(sandbox|vm\.createContext|nsjail|firejail|seccomp|chroot|namespaces)\b", re.IGNORECASE)),
141
+ ("shell-exec", re.compile(r"\b(execSync|spawnSync|os\.system|subprocess\.run)\b")),
142
+ ("network-egress", re.compile(r"\b(fetch\s*\(|http\.get|https\.get|requests\.get|net\.Dial)\b")),
143
+ ("shield-gate", re.compile(r"\b(scan_inbound|scan_recalled|ShieldGuard|ShieldManager|Shield\(\))\b")),
144
+ )
145
+
146
+ _FLOW_DETECTORS: Final[Sequence[tuple[FlowSource, FlowSink, Pattern[str], int]]] = (
147
+ ("user-message", "shell", re.compile(
148
+ r"\b(child_process\.(exec|spawn)|execSync|spawnSync|os\.system|subprocess\.(run|call|Popen))"
149
+ r"\s*\([^)]*\b(req|request|input|message|body|userInput|args\.[a-zA-Z_])\b",
150
+ re.IGNORECASE,
151
+ ), 9),
152
+ ("http-input", "sql", re.compile(
153
+ r"\b(query|execute|raw)\s*\(\s*[`'\"][^`'\"]*\$\{[^}]+\}",
154
+ ), 8),
155
+ ("env-var", "shell", re.compile(
156
+ r"(execSync|spawn|os\.system)\s*\([^)]*process\.env\.",
157
+ ), 7),
158
+ ("network-fetch", "agent-prompt", re.compile(
159
+ r"\b(fetch|http\.get|requests\.get)[^;]*\.(text|json)\(\)[^;]*\b(prompt|system|message)",
160
+ re.IGNORECASE,
161
+ ), 8),
162
+ ("file-read", "agent-prompt", re.compile(
163
+ r"\b(readFileSync|readFile)[^;]*\.\s*(prompt|system|content)",
164
+ ), 6),
165
+ ("memory-recall", "agent-prompt", re.compile(
166
+ r"\b(recall|searchAcrossSessions)\s*\(",
167
+ ), 4),
168
+ )
169
+
170
+ # ─── Ignore globs ────────────────────────────────────────────────────────────
171
+
172
+ _IGNORE_PATTERNS: Final[Sequence[Pattern[str]]] = tuple(
173
+ re.compile(p)
174
+ for p in (
175
+ r"(^|/)\.next/",
176
+ r"(^|/)\.turbo/",
177
+ r"(^|/)node_modules/",
178
+ r"(^|/)dist/",
179
+ r"(^|/)build/",
180
+ r"(^|/)target/",
181
+ r"(^|/)\.git/",
182
+ r"(^|/)coverage/",
183
+ r"(^|/)__pycache__/",
184
+ r"\.lock$",
185
+ r"\.min\.(js|css)$",
186
+ r"\.map$",
187
+ r"\.png$",
188
+ r"\.jpg$",
189
+ r"\.jpeg$",
190
+ r"\.gif$",
191
+ r"\.webp$",
192
+ r"\.pdf$",
193
+ r"\.zip$",
194
+ r"\.tar\.gz$",
195
+ )
196
+ )
197
+
198
+ _TEXT_EXTS: Final[frozenset[str]] = frozenset({
199
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
200
+ ".py", ".pyi", ".go", ".rs", ".rb", ".java", ".kt", ".swift",
201
+ ".php", ".cs", ".cpp", ".cc", ".c", ".h", ".hpp",
202
+ ".sh", ".bash", ".zsh", ".fish",
203
+ ".html", ".vue", ".svelte",
204
+ ".yaml", ".yml", ".toml", ".json", ".env",
205
+ })
206
+
207
+
208
+ # ─── Public API ──────────────────────────────────────────────────────────────
209
+
210
+
211
+ class AttackSurfaceMapper:
212
+ """
213
+ Lyrie Attack-Surface Mapper.
214
+
215
+ Lyrie.ai by OTT Cybersecurity LLC.
216
+ """
217
+
218
+ __slots__ = ("_root", "_files", "_max_files", "_max_bytes", "_shield", "_deps_only")
219
+
220
+ def __init__(
221
+ self,
222
+ root: str | Path = ".",
223
+ *,
224
+ files: Optional[Iterable[str]] = None,
225
+ max_files: int = 5_000,
226
+ max_bytes_per_file: int = 200_000,
227
+ shield: Optional[Shield] = None,
228
+ deps_only: bool = False,
229
+ ) -> None:
230
+ self._root = Path(root).resolve()
231
+ self._files = list(files) if files is not None else None
232
+ self._max_files = max_files
233
+ self._max_bytes = max_bytes_per_file
234
+ self._shield = shield or Shield()
235
+ self._deps_only = deps_only
236
+
237
+ def run(self) -> AttackSurface:
238
+ files = self._resolve_files()
239
+ entries: list[EntryPoint] = []
240
+ boundaries: list[TrustBoundary] = []
241
+ flows: list[DataFlow] = []
242
+ inspected = ignored = shielded = 0
243
+
244
+ if not self._deps_only:
245
+ for rel in files:
246
+ if any(p.search(rel) for p in _IGNORE_PATTERNS):
247
+ ignored += 1
248
+ continue
249
+ ext = Path(rel).suffix.lower()
250
+ if ext not in _TEXT_EXTS:
251
+ ignored += 1
252
+ continue
253
+ abs_path = self._root / rel
254
+ if not abs_path.is_file():
255
+ continue
256
+ try:
257
+ content = abs_path.read_text("utf-8", errors="replace")[: self._max_bytes]
258
+ except OSError:
259
+ continue
260
+ if "lyrie-shield: ignore-file" in content[:4096]:
261
+ ignored += 1
262
+ continue
263
+
264
+ self._detect_into(content, rel, entries, boundaries, flows)
265
+ inspected += 1
266
+
267
+ deps = _collect_dependencies(self._root)
268
+ hotspots = _rank_hotspots(self._root, files, entries, boundaries, flows)
269
+
270
+ return AttackSurface(
271
+ root=str(self._root),
272
+ files_inspected=inspected,
273
+ files_ignored=ignored,
274
+ files_shielded=shielded,
275
+ entry_points=entries,
276
+ trust_boundaries=boundaries,
277
+ data_flows=flows,
278
+ dependencies=deps,
279
+ hotspots=hotspots,
280
+ generated_at=datetime.now(tz=timezone.utc).isoformat(),
281
+ )
282
+
283
+ # ── internals ──────────────────────────────────────────────────────────
284
+
285
+ def _resolve_files(self) -> list[str]:
286
+ if self._files is not None:
287
+ return self._files[: self._max_files]
288
+ return _list_repo_files(self._root, self._max_files)
289
+
290
+ def _detect_into(
291
+ self,
292
+ content: str,
293
+ file: str,
294
+ entries: list[EntryPoint],
295
+ boundaries: list[TrustBoundary],
296
+ flows: list[DataFlow],
297
+ ) -> None:
298
+ for kind, rx in _ENTRY_DETECTORS:
299
+ for m in rx.finditer(content):
300
+ entries.append(EntryPoint(
301
+ kind=kind, file=file, line=_line_of(content, m.start()),
302
+ evidence=_excerpt(content, m.start()),
303
+ ))
304
+ for kind, rx in _BOUNDARY_DETECTORS:
305
+ for m in rx.finditer(content):
306
+ boundaries.append(TrustBoundary(
307
+ kind=kind, file=file, line=_line_of(content, m.start()),
308
+ evidence=_excerpt(content, m.start()),
309
+ ))
310
+ for source, sink, rx, risk in _FLOW_DETECTORS:
311
+ for m in rx.finditer(content):
312
+ ev = _excerpt(content, m.start())
313
+ if self._shield.scan_recalled(ev).blocked:
314
+ continue
315
+ flows.append(DataFlow(
316
+ source=source, sink=sink, file=file,
317
+ line=_line_of(content, m.start()),
318
+ evidence=ev, risk=risk,
319
+ ))
320
+
321
+
322
+ # ─── Helpers ────────────────────────────────────────────────────────────────
323
+
324
+
325
+ def _line_of(content: str, idx: int) -> int:
326
+ return content.count("\n", 0, idx) + 1
327
+
328
+
329
+ def _excerpt(content: str, idx: int, *, span: int = 80) -> str:
330
+ start = max(0, idx - 20)
331
+ end = min(len(content), idx + span)
332
+ return " ".join(content[start:end].split())
333
+
334
+
335
+ def _list_repo_files(root: Path, limit: int) -> list[str]:
336
+ try:
337
+ result = subprocess.run(
338
+ ["git", "-C", str(root), "ls-files"],
339
+ check=True, text=True, capture_output=True, timeout=15,
340
+ )
341
+ if result.stdout:
342
+ return [ln for ln in result.stdout.splitlines() if ln][:limit]
343
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
344
+ pass
345
+ return _walk_dir(root, limit)
346
+
347
+
348
+ def _walk_dir(root: Path, limit: int) -> list[str]:
349
+ out: list[str] = []
350
+ for path in root.rglob("*"):
351
+ if not path.is_file():
352
+ continue
353
+ rel = str(path.relative_to(root))
354
+ if any(p.search(rel) for p in _IGNORE_PATTERNS):
355
+ continue
356
+ out.append(rel)
357
+ if len(out) >= limit:
358
+ break
359
+ return out
360
+
361
+
362
+ def _collect_dependencies(root: Path) -> list[DependencyEntry]:
363
+ deps: list[DependencyEntry] = []
364
+ pkg = root / "package.json"
365
+ if pkg.is_file():
366
+ try:
367
+ import json
368
+ data = json.loads(pkg.read_text("utf-8"))
369
+ for section in ("dependencies", "devDependencies", "peerDependencies"):
370
+ for name, version in (data.get(section) or {}).items():
371
+ deps.append(DependencyEntry(
372
+ name=name, version=str(version),
373
+ manifest="package.json", ecosystem="npm",
374
+ ))
375
+ except (OSError, ValueError):
376
+ pass
377
+ pyproject = root / "pyproject.toml"
378
+ if pyproject.is_file():
379
+ try:
380
+ content = pyproject.read_text("utf-8")
381
+ for m in re.finditer(r"^\s*([a-zA-Z0-9_\-]+)\s*=\s*[\"'][^\"']+[\"']", content, re.MULTILINE):
382
+ deps.append(DependencyEntry(
383
+ name=m.group(1), manifest="pyproject.toml", ecosystem="pip",
384
+ ))
385
+ except OSError:
386
+ pass
387
+ return deps
388
+
389
+
390
+ _KIND_WEIGHT: Final[dict[str, int]] = {
391
+ "http-route": 3, "websocket": 3,
392
+ "deserialization-sink": 4, "subprocess": 4,
393
+ "file-reader": 1, "env-consumer": 1,
394
+ "render": 2, "cli-command": 2, "cron": 2,
395
+ }
396
+
397
+
398
+ def _rank_hotspots(
399
+ root: Path,
400
+ files: list[str],
401
+ entries: list[EntryPoint],
402
+ boundaries: list[TrustBoundary],
403
+ flows: list[DataFlow],
404
+ ) -> list[RiskHotspot]:
405
+ scores: dict[str, dict] = {}
406
+
407
+ def bump(file: str, score: int, reason: str) -> None:
408
+ if file not in scores:
409
+ scores[file] = {"score": 0, "reasons": set()}
410
+ scores[file]["score"] += score
411
+ scores[file]["reasons"].add(reason)
412
+
413
+ for ep in entries:
414
+ bump(ep.file, _KIND_WEIGHT.get(ep.kind, 1), f"entry:{ep.kind}")
415
+ for tb in boundaries:
416
+ bump(tb.file, 1, f"boundary:{tb.kind}")
417
+ for f in flows:
418
+ bump(f.file, f.risk, f"flow:{f.source}->{f.sink}")
419
+
420
+ now = time.time()
421
+ for file in files:
422
+ path = root / file
423
+ try:
424
+ mtime = path.stat().st_mtime
425
+ if now - mtime < 30 * 24 * 3600 and file in scores:
426
+ scores[file]["score"] += 1
427
+ scores[file]["reasons"].add("recently-edited")
428
+ except OSError:
429
+ pass
430
+
431
+ return [
432
+ RiskHotspot(
433
+ file=file,
434
+ score=min(10, info["score"]),
435
+ reasons=sorted(info["reasons"]),
436
+ )
437
+ for file, info in sorted(scores.items(), key=lambda kv: -kv[1]["score"])[:25]
438
+ ]
lyrie/cli.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ `lyrie-py` CLI — operator entry point for the Python SDK.
3
+
4
+ Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from lyrie import (
15
+ AttackSurfaceMapper,
16
+ Finding,
17
+ Shield,
18
+ StagesValidator,
19
+ __version__,
20
+ scan_files,
21
+ )
22
+ from lyrie.threat_intel import ThreatIntelClient
23
+
24
+
25
+ def _print_header(title: str) -> None:
26
+ print()
27
+ print(f"🛡️ {title} · Lyrie.ai by OTT Cybersecurity LLC")
28
+ print("─" * 65)
29
+
30
+
31
+ def _shield(args: argparse.Namespace) -> int:
32
+ shield = Shield()
33
+ text = " ".join(args.text)
34
+ verdict = shield.scan_recalled(text) if args.mode == "recalled" else shield.scan_inbound(text)
35
+ print(json.dumps({
36
+ "blocked": verdict.blocked,
37
+ "severity": verdict.severity,
38
+ "reason": verdict.reason,
39
+ "signature": verdict.signature,
40
+ }, indent=2))
41
+ return 0 if not verdict.blocked else 1
42
+
43
+
44
+ def _understand(args: argparse.Namespace) -> int:
45
+ surface = AttackSurfaceMapper(root=args.root).run()
46
+ if args.json:
47
+ from dataclasses import asdict
48
+ print(json.dumps(asdict(surface), indent=2))
49
+ return 0
50
+ _print_header("Lyrie Attack-Surface Map")
51
+ print(f" root: {surface.root}")
52
+ print(f" files seen: {surface.files_inspected} (ignored {surface.files_ignored})")
53
+ print(f" entries: {len(surface.entry_points)}")
54
+ print(f" boundaries: {len(surface.trust_boundaries)}")
55
+ print(f" flows: {len(surface.data_flows)}")
56
+ print(f" dependencies: {len(surface.dependencies)}")
57
+ if surface.hotspots:
58
+ print()
59
+ print("🔥 Top hotspots")
60
+ for h in surface.hotspots[:10]:
61
+ print(f" [{h.score:>2}] {h.file}")
62
+ for r in h.reasons[:4]:
63
+ print(f" {r}")
64
+ print()
65
+ print(f"signature: {surface.signature}")
66
+ print()
67
+ return 0
68
+
69
+
70
+ def _scan_files(args: argparse.Namespace) -> int:
71
+ report = scan_files(root=args.root)
72
+ if args.json:
73
+ from dataclasses import asdict
74
+ print(json.dumps({
75
+ "scanned_files": report.scanned_files,
76
+ "findings": [asdict(f) for f in report.findings],
77
+ "languages": report.languages,
78
+ "signature": report.signature,
79
+ }, indent=2))
80
+ return 0
81
+ _print_header("Lyrie Multi-Language Scan")
82
+ print(f" files scanned: {report.scanned_files}")
83
+ print(f" findings: {len(report.findings)}")
84
+ print(f" languages: {' '.join(f'{l}({n})' for l, n in report.languages)}")
85
+ print()
86
+ for f in report.findings[:50]:
87
+ print(f" [{f.severity.upper():>8}] {f.title}")
88
+ print(f" {f.file}:{f.line}")
89
+ print()
90
+ print(f"signature: {report.signature}")
91
+ print()
92
+ return 0
93
+
94
+
95
+ def _validate(args: argparse.Namespace) -> int:
96
+ finding = Finding(
97
+ id=args.id,
98
+ title=args.title,
99
+ severity=args.severity,
100
+ description=args.description,
101
+ file=args.file,
102
+ line=args.line,
103
+ cwe=args.cwe,
104
+ category=args.category, # type: ignore[arg-type]
105
+ evidence=args.evidence,
106
+ )
107
+ validator = StagesValidator()
108
+ v = validator.validate(finding)
109
+ print(json.dumps({
110
+ "confirmed": v.confirmed,
111
+ "confidence": round(v.confidence, 2),
112
+ "stages": [{"stage": s.stage, "passed": s.passed, "reason": s.reason}
113
+ for s in v.stages],
114
+ "poc": v.poc.payload if v.poc else None,
115
+ "remediation": v.remediation.summary if v.remediation else None,
116
+ "signature": v.signature,
117
+ }, indent=2))
118
+ return 0
119
+
120
+
121
+ def _intel(args: argparse.Namespace) -> int:
122
+ client = ThreatIntelClient(offline=args.offline)
123
+ ads = client.get_advisories()
124
+ if args.json:
125
+ from dataclasses import asdict
126
+ print(json.dumps([asdict(a) for a in ads], indent=2))
127
+ return 0
128
+ _print_header("Lyrie Threat-Intel")
129
+ if not ads:
130
+ print(" (no advisories — feed unreachable, offline, or empty)")
131
+ else:
132
+ for a in ads[:25]:
133
+ kev = " 🚨 KEV" if a.kev.in_kev else ""
134
+ print(f" {a.cve} [{a.severity.upper()}]{kev} {a.title}")
135
+ print()
136
+ return 0
137
+
138
+
139
+ def main(argv: list[str] | None = None) -> int:
140
+ parser = argparse.ArgumentParser(
141
+ prog="lyrie-py",
142
+ description=f"Lyrie Agent Python SDK CLI — Lyrie.ai by OTT Cybersecurity LLC (v{__version__})",
143
+ )
144
+ parser.add_argument("--version", action="version", version=f"lyrie-agent {__version__}")
145
+ sub = parser.add_subparsers(dest="cmd", required=True)
146
+
147
+ p_shield = sub.add_parser("shield", help="Run text through the Shield")
148
+ p_shield.add_argument("text", nargs="+")
149
+ p_shield.add_argument("--mode", choices=("recalled", "inbound"), default="recalled")
150
+ p_shield.set_defaults(func=_shield)
151
+
152
+ p_und = sub.add_parser("understand", help="Lyrie Attack-Surface Mapper")
153
+ p_und.add_argument("--root", default=".")
154
+ p_und.add_argument("--json", action="store_true")
155
+ p_und.set_defaults(func=_understand)
156
+
157
+ p_scan = sub.add_parser("scan-files", help="Lyrie multi-language scanners")
158
+ p_scan.add_argument("--root", default=".")
159
+ p_scan.add_argument("--json", action="store_true")
160
+ p_scan.set_defaults(func=_scan_files)
161
+
162
+ p_val = sub.add_parser("validate-finding", help="Run a finding through Stages A–F")
163
+ p_val.add_argument("--id", default="cli-1")
164
+ p_val.add_argument("--title", default="CLI test finding")
165
+ p_val.add_argument("--severity", default="high")
166
+ p_val.add_argument("--description", default="Submitted via lyrie-py CLI")
167
+ p_val.add_argument("--file")
168
+ p_val.add_argument("--line", type=int)
169
+ p_val.add_argument("--cwe")
170
+ p_val.add_argument("--category", default="other")
171
+ p_val.add_argument("--evidence", default="")
172
+ p_val.set_defaults(func=_validate)
173
+
174
+ p_intel = sub.add_parser("intel", help="Lyrie Threat-Intel feed")
175
+ p_intel.add_argument("--offline", action="store_true")
176
+ p_intel.add_argument("--json", action="store_true")
177
+ p_intel.set_defaults(func=_intel)
178
+
179
+ args = parser.parse_args(argv)
180
+ return args.func(args)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ sys.exit(main())