lyss 0.8.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.
- lyss/__init__.py +7 -0
- lyss/__main__.py +248 -0
- lyss/_bundled/adapters/cisco_ios.yaml +260 -0
- lyss/_bundled/adapters/cisco_sb.yaml +268 -0
- lyss/_bundled/adapters/hp_procurve.yaml +248 -0
- lyss/_bundled/adapters/mikrotik.yaml +292 -0
- lyss/_bundled/adapters/sonic.yaml +218 -0
- lyss/_bundled/data/firmware.yaml +147 -0
- lyss/_bundled/spec/lyss-spec.yaml +345 -0
- lyss/changes.py +123 -0
- lyss/collector.py +488 -0
- lyss/config.py +126 -0
- lyss/discovery.py +277 -0
- lyss/engine.py +505 -0
- lyss/events.py +130 -0
- lyss/firmware.py +304 -0
- lyss/history.py +159 -0
- lyss/inventory.py +220 -0
- lyss/notify.py +145 -0
- lyss/oxidized.py +125 -0
- lyss/remediate.py +450 -0
- lyss/report.py +93 -0
- lyss/scan.py +110 -0
- lyss/scheduler.py +81 -0
- lyss/sim/__init__.py +10 -0
- lyss/sim/config.py +200 -0
- lyss/sim/demo.py +147 -0
- lyss/sim/lab.py +203 -0
- lyss/sim/labs/demo/demo-acc-01.cfg +143 -0
- lyss/sim/labs/demo/demo-acc-02.cfg +136 -0
- lyss/sim/labs/demo/demo-core.cfg +304 -0
- lyss/sim/labs/demo/demo-crs-01.cfg +18 -0
- lyss/sim/labs/demo/demo-crs-01.state +90 -0
- lyss/sim/labs/demo/demo-hp-01.cfg +20 -0
- lyss/sim/labs/demo/demo-leaf-01.cfg +41 -0
- lyss/sim/labs/demo/demo-leaf-01.state +53 -0
- lyss/sim/labs/demo/demo-sb-01.cfg +118 -0
- lyss/sim/labs/demo/demo-sb-02.cfg +99 -0
- lyss/sim/labs/demo/lab.yaml +144 -0
- lyss/sim/render.py +445 -0
- lyss/sim/server.py +437 -0
- lyss/sites.py +162 -0
- lyss/topology.py +303 -0
- lyss/vault.py +109 -0
- lyss/web/__init__.py +2 -0
- lyss/web/app.py +996 -0
- lyss/web/static/app.js +139 -0
- lyss/web/static/favicon.svg +9 -0
- lyss/web/static/firmware.js +19 -0
- lyss/web/static/maroc.svg +25 -0
- lyss/web/static/remediation.js +57 -0
- lyss/web/static/style.css +382 -0
- lyss/web/static/topology.js +290 -0
- lyss/web/static/zellige.svg +18 -0
- lyss/web/templates/_hero.html +14 -0
- lyss/web/templates/_macros.html +16 -0
- lyss/web/templates/backup_diff.html +22 -0
- lyss/web/templates/base.html +47 -0
- lyss/web/templates/dashboard.html +163 -0
- lyss/web/templates/device.html +147 -0
- lyss/web/templates/device_form.html +78 -0
- lyss/web/templates/discovery.html +110 -0
- lyss/web/templates/firmware.html +79 -0
- lyss/web/templates/login.html +20 -0
- lyss/web/templates/matrix.html +48 -0
- lyss/web/templates/remediation.html +96 -0
- lyss/web/templates/report.html +154 -0
- lyss/web/templates/settings.html +270 -0
- lyss/web/templates/setup.html +22 -0
- lyss/web/templates/topology.html +45 -0
- lyss-0.8.0.dist-info/METADATA +210 -0
- lyss-0.8.0.dist-info/RECORD +79 -0
- lyss-0.8.0.dist-info/WHEEL +5 -0
- lyss-0.8.0.dist-info/entry_points.txt +2 -0
- lyss-0.8.0.dist-info/licenses/LICENSE +661 -0
- lyss-0.8.0.dist-info/licenses/LICENSE-SPEC +396 -0
- lyss-0.8.0.dist-info/licenses/NOTICE +43 -0
- lyss-0.8.0.dist-info/licenses/TRADEMARKS.md +32 -0
- lyss-0.8.0.dist-info/top_level.txt +1 -0
lyss/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright 2026 Mouad Lyaazale. Additional terms (author attribution, names): see NOTICE.
|
|
3
|
+
"""LYSS: Lyaazale Switch Security."""
|
|
4
|
+
|
|
5
|
+
PROJECT_NAME = "LYSS"
|
|
6
|
+
PROJECT_LONG_NAME = "Lyaazale Switch Security"
|
|
7
|
+
__version__ = "0.8.0"
|
lyss/__main__.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright 2026 Mouad Lyaazale. Additional terms (author attribution, names): see NOTICE.
|
|
3
|
+
"""Command line.
|
|
4
|
+
|
|
5
|
+
python -m lyss audit --vendor <adapter> <file-or-folder> assess saved configurations
|
|
6
|
+
python -m lyss collect [--only id ...] collect + audit inventory devices
|
|
7
|
+
python -m lyss oxidized --url http://oxidized:8888 assess the configs backed up by Oxidized
|
|
8
|
+
python -m lyss import-legacy <inventory.csv> <cred-dir> import devices + PowerShell cred files
|
|
9
|
+
python -m lyss ui [--port 8765] start the local web interface
|
|
10
|
+
python -m lyss sim [--lab demo] [--base-port 2201] simulated switches over SSH (127.0.0.1)
|
|
11
|
+
python -m lyss demo [--port 8766] [--reset] web interface on the simulated demo lab
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import csv
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import asdict
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from . import PROJECT_LONG_NAME, PROJECT_NAME, __version__
|
|
23
|
+
from .engine import audit, conformance, load_spec
|
|
24
|
+
|
|
25
|
+
ICONS = {"pass": "PASS", "fail": "FAIL", "unknown": "????", "not_applicable": "n/a "}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def print_text(name: str, findings, summary) -> None:
|
|
29
|
+
print(f"\n=== {name} -> {PROJECT_NAME} Level {summary['achieved_level']} "
|
|
30
|
+
f"(pass {summary['pass']}, fail {summary['fail']}, unknown {summary['unknown']})")
|
|
31
|
+
for f in findings:
|
|
32
|
+
print(f" [{ICONS[f.verdict]}] {f.control_id} {f.title}")
|
|
33
|
+
for e in f.evidence[:3]:
|
|
34
|
+
print(f" evidence: {e}")
|
|
35
|
+
if f.note:
|
|
36
|
+
print(f" note: {f.note}")
|
|
37
|
+
if f.remediation:
|
|
38
|
+
for line in f.remediation.splitlines():
|
|
39
|
+
print(f" fix> {line}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_audit(args) -> int:
|
|
43
|
+
# a device = <name>.cfg (configuration) + optional <name>.state.txt (collected device state)
|
|
44
|
+
files = sorted(args.path.glob("*.cfg")) if args.path.is_dir() else [args.path]
|
|
45
|
+
results = []
|
|
46
|
+
for path in files:
|
|
47
|
+
state_file = path.with_name(path.stem + ".state.txt")
|
|
48
|
+
state_text = state_file.read_text(encoding="utf-8", errors="replace") if state_file.exists() else ""
|
|
49
|
+
findings = audit(path.read_text(encoding="utf-8", errors="replace"), args.vendor, args.level, state_text)
|
|
50
|
+
summary = conformance(findings)
|
|
51
|
+
if args.json:
|
|
52
|
+
results.append({"device": path.name, "vendor": args.vendor,
|
|
53
|
+
"summary": summary, "findings": [asdict(f) for f in findings]})
|
|
54
|
+
else:
|
|
55
|
+
print_text(path.name, findings, summary)
|
|
56
|
+
|
|
57
|
+
if args.json:
|
|
58
|
+
json.dump({"tool": PROJECT_NAME, "version": __version__,
|
|
59
|
+
"spec_version": load_spec()["spec_version"], "results": results},
|
|
60
|
+
sys.stdout, indent=2, ensure_ascii=False)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cmd_collect(args) -> int:
|
|
65
|
+
from . import inventory, scan
|
|
66
|
+
|
|
67
|
+
ids = args.only or [d["id"] for d in inventory.load_devices()]
|
|
68
|
+
started = time.monotonic()
|
|
69
|
+
# --scheduled: run by cron / Task Scheduler, counts as an automatic backup (LYSS-BKP-001)
|
|
70
|
+
if not scan.start(ids, trigger="schedule" if args.scheduled else "cli"):
|
|
71
|
+
print("a scan is already running")
|
|
72
|
+
return 1
|
|
73
|
+
shown = {}
|
|
74
|
+
while True:
|
|
75
|
+
job = scan.current_job()
|
|
76
|
+
for device_id, item in job["items"].items():
|
|
77
|
+
if shown.get(device_id) != item["status"] and item["status"] in ("done", "error"):
|
|
78
|
+
print(f"{device_id:18} {item['status']:6} {item['message']}")
|
|
79
|
+
shown[device_id] = item["status"]
|
|
80
|
+
if job["finished"]:
|
|
81
|
+
break
|
|
82
|
+
time.sleep(0.5)
|
|
83
|
+
for notice in job.get("notices", []):
|
|
84
|
+
print(notice)
|
|
85
|
+
print(f"total {time.monotonic() - started:.0f}s")
|
|
86
|
+
return 1 if any(i["status"] == "error" for i in job["items"].values()) else 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_oxidized(args) -> int:
|
|
90
|
+
"""Assess the configurations backed up by Oxidized (REST API), without connecting to the switches."""
|
|
91
|
+
import os
|
|
92
|
+
|
|
93
|
+
from . import events, oxidized
|
|
94
|
+
password = os.environ.get("LYSS_OXIDIZED_PASSWORD", "") # never on the command line
|
|
95
|
+
with oxidized.client(args.url, args.user or "", password, not args.insecure) as http:
|
|
96
|
+
results, skipped = oxidized.assess_all(http, args.level, args.only)
|
|
97
|
+
if args.json:
|
|
98
|
+
json.dump({"tool": PROJECT_NAME, "version": __version__, "source": "oxidized",
|
|
99
|
+
"spec_version": load_spec()["spec_version"], "results": results, "skipped": skipped},
|
|
100
|
+
sys.stdout, indent=2, ensure_ascii=False)
|
|
101
|
+
else:
|
|
102
|
+
from .engine import Finding
|
|
103
|
+
for r in results:
|
|
104
|
+
print_text(f"{r['device']} ({r['host']}, {r['vendor']}, from Oxidized)",
|
|
105
|
+
[Finding(**f) for f in r["findings"]], r["summary"])
|
|
106
|
+
for s in skipped:
|
|
107
|
+
print(f"\n--- {s['device']}: skipped, {s['reason']}")
|
|
108
|
+
print(f"\n{len(results)} switch(es) assessed from Oxidized, {len(skipped)} skipped.")
|
|
109
|
+
if args.events:
|
|
110
|
+
cfg = events.settings()
|
|
111
|
+
if not (cfg.get("host") or cfg.get("file")):
|
|
112
|
+
print("SIEM events: configure a syslog server or an events file in Settings first.", file=sys.stderr)
|
|
113
|
+
return 2
|
|
114
|
+
sent = events.send(events.wrap([e for r in results for e in events.result_events(
|
|
115
|
+
{"device": r["device"], "host": r["host"], "site": r["group"]}, r, {}, cfg.get("findings", "all"),
|
|
116
|
+
"oxidized")]), cfg)
|
|
117
|
+
print(f"{sent} event(s) sent to the SIEM.", file=sys.stderr)
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_import_legacy(args) -> int:
|
|
122
|
+
"""Devices from a CSV (name,vendor,ip,cred) + PowerShell Export-Clixml credential files.
|
|
123
|
+
The DPAPI-protected password is copied as-is: it is never decrypted during the import."""
|
|
124
|
+
from . import inventory, vault
|
|
125
|
+
|
|
126
|
+
devices = inventory.load_devices()
|
|
127
|
+
for row in csv.DictReader(args.csv.open(encoding="utf-8")):
|
|
128
|
+
user, protected = vault.read_ps_credential(args.cred_dir / row["cred"])
|
|
129
|
+
record = {"id": row["name"], "host": row["ip"], "port": 22, "vendor": row["vendor"],
|
|
130
|
+
"username": user, "via_jump": args.via_jump, "secret": protected}
|
|
131
|
+
devices = [d for d in devices if d["id"] != record["id"]] + [record]
|
|
132
|
+
print(f"imported {row['name']}")
|
|
133
|
+
inventory._write(inventory.DEVICES_FILE, sorted(devices, key=lambda d: d["id"]))
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_ui(args) -> int:
|
|
138
|
+
import os
|
|
139
|
+
|
|
140
|
+
import uvicorn
|
|
141
|
+
|
|
142
|
+
from . import scheduler
|
|
143
|
+
from .web.app import app
|
|
144
|
+
if args.host not in ("127.0.0.1", "localhost", "::1") and os.environ.get("LYSS_ALLOW_REMOTE") != "1":
|
|
145
|
+
# inside a container the UI must listen on all interfaces; publish the port on 127.0.0.1
|
|
146
|
+
# only, or put an HTTPS reverse proxy in front: the UI itself speaks plain HTTP
|
|
147
|
+
print("Refusing to listen on a non-local address without LYSS_ALLOW_REMOTE=1.")
|
|
148
|
+
return 2
|
|
149
|
+
scheduler.start_background()
|
|
150
|
+
print(f"{PROJECT_LONG_NAME} UI on http://{args.host}:{args.port} (Ctrl+C to stop)")
|
|
151
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level="warning", server_header=False)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cmd_sim(args) -> int:
|
|
156
|
+
import tempfile
|
|
157
|
+
|
|
158
|
+
from .sim import Simulator, load_lab
|
|
159
|
+
lab = load_lab(args.lab)
|
|
160
|
+
keys = args.keys_dir or Path(tempfile.gettempdir()) / "lyss-sim-keys" / lab.name
|
|
161
|
+
sim = Simulator(lab, base_port=args.base_port, keys_dir=keys)
|
|
162
|
+
try:
|
|
163
|
+
sim.start()
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
print(f"Cannot open the simulator ports from {args.base_port}: {exc}")
|
|
166
|
+
return 2
|
|
167
|
+
print(f"{PROJECT_NAME} simulator - lab '{lab.name}' ({lab.organization}, fictitious devices)")
|
|
168
|
+
for d in sim.describe():
|
|
169
|
+
print(f" {d['id']:<12} {d['platform']:<12} {d['hostname']:<14} {d['model']:<16} 127.0.0.1:{d['port']}")
|
|
170
|
+
print(f" SSH login: {lab.credentials['username']} / {lab.credentials['password']} (Ctrl+C to stop)")
|
|
171
|
+
try:
|
|
172
|
+
while True:
|
|
173
|
+
time.sleep(1)
|
|
174
|
+
except KeyboardInterrupt:
|
|
175
|
+
pass
|
|
176
|
+
finally:
|
|
177
|
+
sim.stop()
|
|
178
|
+
return 0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_demo(args) -> int:
|
|
182
|
+
from .sim import demo
|
|
183
|
+
try:
|
|
184
|
+
return demo.run(args.lab, args.data_dir, args.port, args.base_port, args.reset, args.open)
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
print(exc)
|
|
187
|
+
return 2
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def main(argv=None) -> int:
|
|
191
|
+
parser = argparse.ArgumentParser(prog="lyss", description=f"{PROJECT_LONG_NAME} {__version__}")
|
|
192
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
193
|
+
|
|
194
|
+
a = sub.add_parser("audit", help="assess saved configuration files")
|
|
195
|
+
a.add_argument("--vendor", required=True, help="adapter name, e.g. cisco_ios, cisco_sb, hp_procurve, mikrotik, sonic")
|
|
196
|
+
a.add_argument("--level", type=int, default=1, help="highest level to assess (default 1)")
|
|
197
|
+
a.add_argument("--json", action="store_true", help="output JSON instead of text")
|
|
198
|
+
a.add_argument("path", type=Path)
|
|
199
|
+
a.set_defaults(func=cmd_audit)
|
|
200
|
+
|
|
201
|
+
c = sub.add_parser("collect", help="collect and audit inventory devices (read-only)")
|
|
202
|
+
c.add_argument("--only", nargs="*", help="device ids (default: all)")
|
|
203
|
+
c.add_argument("--scheduled", action="store_true",
|
|
204
|
+
help="run from cron / Task Scheduler: counts as an automatic configuration backup")
|
|
205
|
+
c.set_defaults(func=cmd_collect)
|
|
206
|
+
|
|
207
|
+
o = sub.add_parser("oxidized", help="assess the configurations backed up by Oxidized (no SSH to the switches)")
|
|
208
|
+
o.add_argument("--url", required=True, help="Oxidized REST API, e.g. http://oxidized:8888")
|
|
209
|
+
o.add_argument("--user", help="HTTP basic auth user (password in LYSS_OXIDIZED_PASSWORD)")
|
|
210
|
+
o.add_argument("--insecure", action="store_true", help="do not verify the TLS certificate")
|
|
211
|
+
o.add_argument("--level", type=int, default=2, help="highest level to assess (default 2)")
|
|
212
|
+
o.add_argument("--only", nargs="*", help="node names (default: all supported nodes)")
|
|
213
|
+
o.add_argument("--json", action="store_true", help="output JSON instead of text")
|
|
214
|
+
o.add_argument("--events", action="store_true", help="also send SIEM events (Settings > SIEM events)")
|
|
215
|
+
o.set_defaults(func=cmd_oxidized)
|
|
216
|
+
|
|
217
|
+
i = sub.add_parser("import-legacy", help="import devices from CSV + PowerShell cred files")
|
|
218
|
+
i.add_argument("csv", type=Path)
|
|
219
|
+
i.add_argument("cred_dir", type=Path)
|
|
220
|
+
i.add_argument("--via-jump", action="store_true", help="reach these devices through the jump host")
|
|
221
|
+
i.set_defaults(func=cmd_import_legacy)
|
|
222
|
+
|
|
223
|
+
u = sub.add_parser("ui", help="start the local web interface")
|
|
224
|
+
u.add_argument("--port", type=int, default=8765)
|
|
225
|
+
u.add_argument("--host", default="127.0.0.1", help="listen address (non-local needs LYSS_ALLOW_REMOTE=1)")
|
|
226
|
+
u.set_defaults(func=cmd_ui)
|
|
227
|
+
|
|
228
|
+
s = sub.add_parser("sim", help="run simulated switches (fictitious lab) over SSH on 127.0.0.1")
|
|
229
|
+
s.add_argument("--lab", default="demo", help="lab name or folder (default: demo)")
|
|
230
|
+
s.add_argument("--base-port", type=int, default=2201, help="SSH port of the first switch (default 2201)")
|
|
231
|
+
s.add_argument("--keys-dir", type=Path, help="where the simulated host keys are kept")
|
|
232
|
+
s.set_defaults(func=cmd_sim)
|
|
233
|
+
|
|
234
|
+
d = sub.add_parser("demo", help="web interface on the simulated demo lab (separate data directory)")
|
|
235
|
+
d.add_argument("--lab", default="demo")
|
|
236
|
+
d.add_argument("--port", type=int, default=8766, help="web interface port (default 8766)")
|
|
237
|
+
d.add_argument("--base-port", type=int, default=2201, help="SSH port of the first simulated switch")
|
|
238
|
+
d.add_argument("--data-dir", type=Path, help="demo data directory (default: <temp>/lyss-demo)")
|
|
239
|
+
d.add_argument("--reset", action="store_true", help="start again from an empty demo directory")
|
|
240
|
+
d.add_argument("--open", action="store_true", help="open the browser")
|
|
241
|
+
d.set_defaults(func=cmd_demo)
|
|
242
|
+
|
|
243
|
+
args = parser.parse_args(argv)
|
|
244
|
+
return args.func(args)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
sys.exit(main())
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright 2026 Mouad Lyaazale. Additional terms (author attribution, names): see NOTICE.
|
|
3
|
+
# LYSS adapter: Cisco IOS / IOS-XE (Catalyst).
|
|
4
|
+
# Rule forms:
|
|
5
|
+
# {line: R} true if any config line (top level or inside a block) matches R
|
|
6
|
+
# {block: H, line: R} true if any block whose header matches H contains a line matching R
|
|
7
|
+
# {block: H, every_contains: R} true if at least one H block exists and every one contains R
|
|
8
|
+
# add where: W to a block rule only consider H blocks that contain a line matching W
|
|
9
|
+
# add where_not: W to a block rule only consider H blocks with no line matching W
|
|
10
|
+
# add gt: N to a line rule true only if the first regex group, as a number, is > N
|
|
11
|
+
# {state: R, section: S} true if a line of collected state section S matches R
|
|
12
|
+
# add absent: true to a state rule true if section S was collected and no line matches R
|
|
13
|
+
# {ports_in_both: [A, B]} true if some port (group 1) appears in both state tables A and B
|
|
14
|
+
# {captures_in_both: [A, B]} true if a value (group 1) is captured by both A and B, where a
|
|
15
|
+
# capture is {line: R}, {block: H, where: W} (group 1 of the header),
|
|
16
|
+
# {block: H, where: W, line: R}, {untagged_access_vlans: true}
|
|
17
|
+
# (port-list platforms) or a list of these
|
|
18
|
+
# Per control: fail_if_any -> fail; firmware -> data/firmware.yaml; pass_if_all / pass_if_any -> pass;
|
|
19
|
+
# else `otherwise`. A state rule never matches when that state was not collected.
|
|
20
|
+
adapter: cisco_ios
|
|
21
|
+
spec_version: "0.5.0-draft"
|
|
22
|
+
platform: Cisco IOS / IOS-XE
|
|
23
|
+
status: draft, validated on Catalyst C1000 15.2(7)E (real devices, 2026-11)
|
|
24
|
+
|
|
25
|
+
facts:
|
|
26
|
+
model: {section: show inventory, line: '^PID:\s*(\S+)'}
|
|
27
|
+
version: {section: show version, line: 'Version ([^,\s]+)'}
|
|
28
|
+
|
|
29
|
+
checks:
|
|
30
|
+
|
|
31
|
+
# ---------------- Level 1 ----------------
|
|
32
|
+
|
|
33
|
+
LYSS-MGMT-001:
|
|
34
|
+
fail_if_any:
|
|
35
|
+
- {block: '^line vty', line: '^transport input .*\b(telnet|all)\b'}
|
|
36
|
+
- {state: ':23\s.*LISTEN', section: show control-plane host open-ports}
|
|
37
|
+
pass_if_all:
|
|
38
|
+
- {block: '^line vty', every_contains: '^transport input (ssh|none)\s*$'}
|
|
39
|
+
otherwise: fail
|
|
40
|
+
note: Without an explicit "transport input ssh", many IOS versions accept Telnet on vty lines.
|
|
41
|
+
remediation: |
|
|
42
|
+
line vty 0 15
|
|
43
|
+
transport input ssh
|
|
44
|
+
|
|
45
|
+
LYSS-MGMT-002:
|
|
46
|
+
fail_if_any:
|
|
47
|
+
- {state: '^SSH-1\.', section: ssh banner}
|
|
48
|
+
pass_if_any:
|
|
49
|
+
- {line: '^ip ssh version 2\s*$'}
|
|
50
|
+
- {state: '^SSH-2\.0-', section: ssh banner}
|
|
51
|
+
otherwise: fail
|
|
52
|
+
note: Without "ip ssh version 2", IOS runs SSH 1.99 and accepts SSHv1 clients.
|
|
53
|
+
remediation: |
|
|
54
|
+
ip ssh version 2
|
|
55
|
+
|
|
56
|
+
LYSS-MGMT-003:
|
|
57
|
+
fail_if_any:
|
|
58
|
+
- {line: '^ip http server\s*$'}
|
|
59
|
+
- {state: ':80\s.*LISTEN', section: show control-plane host open-ports}
|
|
60
|
+
otherwise: pass
|
|
61
|
+
remediation: |
|
|
62
|
+
no ip http server
|
|
63
|
+
ip http secure-server ! only if the web UI is really needed
|
|
64
|
+
|
|
65
|
+
LYSS-MGMT-004:
|
|
66
|
+
fail_if_any:
|
|
67
|
+
- {block: '^line (con|vty)', line: '^exec-timeout 0( 0)?\s*$'}
|
|
68
|
+
- {block: '^line (con|vty)', line: '^exec-timeout (\d+)', gt: 10}
|
|
69
|
+
otherwise: pass
|
|
70
|
+
note: IOS default is 10 minutes when exec-timeout is not set.
|
|
71
|
+
remediation: |
|
|
72
|
+
line con 0
|
|
73
|
+
exec-timeout 10 0
|
|
74
|
+
line vty 0 15
|
|
75
|
+
exec-timeout 10 0
|
|
76
|
+
|
|
77
|
+
LYSS-AUTH-001:
|
|
78
|
+
fail_if_any:
|
|
79
|
+
- {line: '(?i)^snmp-server community (public|private)\b'}
|
|
80
|
+
otherwise: pass
|
|
81
|
+
remediation: |
|
|
82
|
+
no snmp-server community public
|
|
83
|
+
no snmp-server community private
|
|
84
|
+
! then configure SNMPv3 (LYSS-AUTH-011)
|
|
85
|
+
|
|
86
|
+
LYSS-AUTH-002:
|
|
87
|
+
fail_if_any:
|
|
88
|
+
- {line: '^enable password\b'}
|
|
89
|
+
# type 5 = MD5-crypt, type 4 = flawed unsalted SHA-256: not accepted (cracked quickly)
|
|
90
|
+
- {line: '^enable (algorithm-type md5 )?secret [45] '}
|
|
91
|
+
- {line: '^username \S+ (.* )?privilege 15 (.* )?(secret [45]|password) '}
|
|
92
|
+
pass_if_any:
|
|
93
|
+
- {line: '^enable (algorithm-type (scrypt|sha256) )?secret [89] '}
|
|
94
|
+
# no enable credential at all: privilege 15 only through local accounts with a strong hash
|
|
95
|
+
- {line: '^username \S+ (.* )?privilege 15 (.* )?secret [89] ', say: 'no enable credential; privileged access only through local accounts hashed with scrypt / PBKDF2-SHA-256 (secret 9 / 8)'}
|
|
96
|
+
otherwise: unknown
|
|
97
|
+
note: Accepted hashes are type 9 (scrypt) and type 8 (PBKDF2-SHA-256); without an enable credential, privilege 15 must come from AAA or such local users.
|
|
98
|
+
remediation: |
|
|
99
|
+
no enable password
|
|
100
|
+
enable algorithm-type scrypt secret <new-secret>
|
|
101
|
+
|
|
102
|
+
LYSS-AUTH-003:
|
|
103
|
+
fail_if_any:
|
|
104
|
+
- {line: '^username \S+ .*\bpassword\b'}
|
|
105
|
+
otherwise: pass
|
|
106
|
+
note: 'A "username ... password" line is stored cleartext (type 0) or reversible (type 7); use "secret".'
|
|
107
|
+
remediation: |
|
|
108
|
+
username <name> privilege 15 algorithm-type scrypt secret <new-secret>
|
|
109
|
+
! then remove the old "username <name> password" line
|
|
110
|
+
|
|
111
|
+
LYSS-AUTH-004:
|
|
112
|
+
fail_if_any:
|
|
113
|
+
- {block: '^(radius|tacacs) server', line: '^key (0 )?(?!(6|7) )\S'}
|
|
114
|
+
- {line: '^(radius|tacacs)-server (.* )?key (0 )?(?!(6|7) )\S'}
|
|
115
|
+
otherwise: pass
|
|
116
|
+
note: Type 7 is not cleartext but is reversible; prefer type 6 (AES with a device master key).
|
|
117
|
+
remediation: |
|
|
118
|
+
key config-key password-encrypt <master-key>
|
|
119
|
+
password encryption aes
|
|
120
|
+
! then rotate the shared key on the switch AND on the RADIUS server (NPS)
|
|
121
|
+
|
|
122
|
+
LYSS-VULN-001:
|
|
123
|
+
firmware: true
|
|
124
|
+
otherwise: unknown
|
|
125
|
+
note: Model/firmware not collected, or platform not yet in data/firmware.yaml.
|
|
126
|
+
remediation: |
|
|
127
|
+
! upgrade to the latest release of the train named in the evidence
|
|
128
|
+
! (Catalyst 1000: 15.2(7)E14 or later; security fixes until 2028-04-30)
|
|
129
|
+
! until then, restrict SNMP and management access to the admin network (LYSS-MGMT-010)
|
|
130
|
+
|
|
131
|
+
LYSS-BKP-001:
|
|
132
|
+
backup: true # vendor-neutral: evaluated from the LYSS configuration archive / attestation
|
|
133
|
+
otherwise: unknown
|
|
134
|
+
note: No backup evidence yet. Enable scheduled scans, or attest the external backup system on the switch page.
|
|
135
|
+
remediation: |
|
|
136
|
+
! Settings > Scheduled scans (LYSS keeps every configuration version), or
|
|
137
|
+
! attest the external backup (Oxidized, RANCID...) on the switch page.
|
|
138
|
+
|
|
139
|
+
# ---------------- Level 2 ----------------
|
|
140
|
+
|
|
141
|
+
LYSS-AUTH-010:
|
|
142
|
+
fail_if_any:
|
|
143
|
+
# the default list (used by vty lines without "login authentication") must start with AAA
|
|
144
|
+
- {line: '^aaa authentication login default (?!group\b)\S'}
|
|
145
|
+
# a named list used by a vty line must start with AAA too
|
|
146
|
+
- captures_in_both:
|
|
147
|
+
- {block: '^line vty', line: '^login authentication (\S+)'}
|
|
148
|
+
- {line: '^aaa authentication login (\S+) (?!group\b)\S'}
|
|
149
|
+
pass_if_all:
|
|
150
|
+
- {line: '^aaa new-model'}
|
|
151
|
+
- {line: '^aaa authentication login \S+ group \S+'}
|
|
152
|
+
otherwise: fail
|
|
153
|
+
note: Every login list used for remote access must start with "group <radius/tacacs>"; local only after it.
|
|
154
|
+
remediation: |
|
|
155
|
+
aaa new-model
|
|
156
|
+
aaa authentication login default group <radius-group> local
|
|
157
|
+
aaa authorization exec default group <radius-group> local
|
|
158
|
+
|
|
159
|
+
LYSS-AUTH-011:
|
|
160
|
+
fail_if_any:
|
|
161
|
+
- {line: '^snmp-server community '}
|
|
162
|
+
- {line: '^snmp-server group \S+ v(1|2c?)\b'}
|
|
163
|
+
# SNMPv3 below authPriv: noauth (noAuthNoPriv) or auth (authNoPriv)
|
|
164
|
+
- {line: '^snmp-server group \S+ v3 (noauth|auth)\b'}
|
|
165
|
+
otherwise: pass
|
|
166
|
+
remediation: |
|
|
167
|
+
no snmp-server community <name>
|
|
168
|
+
snmp-server group LYSS v3 priv
|
|
169
|
+
snmp-server user <user> LYSS v3 auth sha <pw> priv aes 128 <pw>
|
|
170
|
+
|
|
171
|
+
LYSS-MGMT-010:
|
|
172
|
+
pass_if_all:
|
|
173
|
+
- {block: '^line vty', every_contains: '^access-class \S+ in'}
|
|
174
|
+
otherwise: fail
|
|
175
|
+
remediation: |
|
|
176
|
+
ip access-list standard MGMT
|
|
177
|
+
permit <admin-subnet> <wildcard>
|
|
178
|
+
line vty 0 15
|
|
179
|
+
access-class MGMT in
|
|
180
|
+
|
|
181
|
+
LYSS-MGMT-011:
|
|
182
|
+
fail_if_any:
|
|
183
|
+
- {block: '^interface Vlan1$', line: '^ip address (\d|dhcp)'}
|
|
184
|
+
# an access port in the management VLAN puts user traffic next to the management address
|
|
185
|
+
- captures_in_both:
|
|
186
|
+
- {block: '^interface Vlan(\d+)$', where: '^ip address \d'}
|
|
187
|
+
- {block: '^interface \S*Ethernet', where: '^switchport mode access', line: '^switchport access vlan (\d+)'}
|
|
188
|
+
pass_if_any:
|
|
189
|
+
- {block: '^interface Vlan(?!1$)\d+$', line: '^ip address \d'}
|
|
190
|
+
otherwise: unknown
|
|
191
|
+
remediation: |
|
|
192
|
+
interface Vlan<mgmt-vlan>
|
|
193
|
+
ip address <ip> <mask>
|
|
194
|
+
interface Vlan1
|
|
195
|
+
no ip address
|
|
196
|
+
shutdown
|
|
197
|
+
|
|
198
|
+
LYSS-MGMT-012:
|
|
199
|
+
pass_if_all:
|
|
200
|
+
- {line: '^banner (login|motd) '}
|
|
201
|
+
- {line: '(?i)authori[sz]ed'}
|
|
202
|
+
otherwise: fail
|
|
203
|
+
note: The login or MOTD banner (shown before authentication) must state the authorized-use restriction.
|
|
204
|
+
remediation: |
|
|
205
|
+
banner login ^Authorized access only. Activity is logged.^
|
|
206
|
+
|
|
207
|
+
LYSS-MGMT-013:
|
|
208
|
+
fail_if_any:
|
|
209
|
+
- {state: '^kex: .*(sha1|group1\b)', section: ssh negotiated}
|
|
210
|
+
- {state: '^cipher: .*(cbc|3des|arcfour|blowfish)', section: ssh negotiated}
|
|
211
|
+
- {state: '^hostkey: (ssh-rsa|ssh-dss)\s*$', section: ssh negotiated}
|
|
212
|
+
pass_if_any:
|
|
213
|
+
- {state: '^kex: ', section: ssh negotiated}
|
|
214
|
+
otherwise: unknown
|
|
215
|
+
note: Needs the negotiated SSH algorithms (collector).
|
|
216
|
+
remediation: |
|
|
217
|
+
ip ssh dh min size 2048
|
|
218
|
+
ip ssh server algorithm encryption aes256-ctr aes192-ctr aes128-ctr
|
|
219
|
+
ip ssh server algorithm mac hmac-sha2-256 hmac-sha2-512
|
|
220
|
+
! if the image offers no SHA-2 key exchange, upgrade IOS (check "ip ssh server algorithm kex ?")
|
|
221
|
+
|
|
222
|
+
LYSS-LOG-010:
|
|
223
|
+
fail_if_any:
|
|
224
|
+
- {line: '^no logging on\b', say: 'logging is disabled globally ("no logging on"): nothing reaches the syslog hosts'}
|
|
225
|
+
pass_if_any:
|
|
226
|
+
- {line: '^logging (host )?\d+\.\d+\.\d+\.\d+'}
|
|
227
|
+
otherwise: fail
|
|
228
|
+
remediation: |
|
|
229
|
+
logging host <siem-ip>
|
|
230
|
+
logging trap informational
|
|
231
|
+
|
|
232
|
+
LYSS-LOG-011:
|
|
233
|
+
fail_if_any:
|
|
234
|
+
- {state: '(?i)\bunsynchroni[sz]ed\b', section: show ntp status}
|
|
235
|
+
pass_if_any:
|
|
236
|
+
- {line: '^ntp server '}
|
|
237
|
+
otherwise: fail
|
|
238
|
+
note: Synchronization is checked with "show ntp status"; without device state, a configured server passes.
|
|
239
|
+
remediation: |
|
|
240
|
+
ntp server <ntp-ip>
|
|
241
|
+
|
|
242
|
+
LYSS-ACC-010:
|
|
243
|
+
fail_if_any:
|
|
244
|
+
- {state: '\bnotconnect\b', section: show interfaces status}
|
|
245
|
+
pass_if_any:
|
|
246
|
+
- {state: '.', section: show interfaces status}
|
|
247
|
+
otherwise: unknown
|
|
248
|
+
note: Needs "show interfaces status" (notconnect = enabled but unused; disabled = shut).
|
|
249
|
+
remediation: |
|
|
250
|
+
interface range <unused-ports>
|
|
251
|
+
shutdown
|
|
252
|
+
switchport access vlan <parking-vlan>
|
|
253
|
+
|
|
254
|
+
LYSS-ACC-011:
|
|
255
|
+
pass_if_any:
|
|
256
|
+
- {line: '^spanning-tree portfast (edge )?bpduguard default'}
|
|
257
|
+
- {block: '^interface \S*Ethernet', where: '^switchport mode access', every_contains: '^spanning-tree bpduguard enable'}
|
|
258
|
+
otherwise: fail
|
|
259
|
+
remediation: |
|
|
260
|
+
spanning-tree portfast edge bpduguard default
|