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 +78 -0
- lyrie/attack_surface.py +438 -0
- lyrie/cli.py +184 -0
- lyrie/edits.py +205 -0
- lyrie/evolve.py +476 -0
- lyrie/oss_scan.py +140 -0
- lyrie/proxy.py +442 -0
- lyrie/redteam.py +572 -0
- lyrie/scanners.py +243 -0
- lyrie/shield.py +115 -0
- lyrie/stages.py +340 -0
- lyrie/threat_intel.py +204 -0
- lyrie_agent-0.3.0.dist-info/METADATA +117 -0
- lyrie_agent-0.3.0.dist-info/RECORD +17 -0
- lyrie_agent-0.3.0.dist-info/WHEEL +4 -0
- lyrie_agent-0.3.0.dist-info/entry_points.txt +2 -0
- lyrie_agent-0.3.0.dist-info/licenses/LICENSE +21 -0
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
|
lyrie/attack_surface.py
ADDED
|
@@ -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())
|