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.
Files changed (79) hide show
  1. lyss/__init__.py +7 -0
  2. lyss/__main__.py +248 -0
  3. lyss/_bundled/adapters/cisco_ios.yaml +260 -0
  4. lyss/_bundled/adapters/cisco_sb.yaml +268 -0
  5. lyss/_bundled/adapters/hp_procurve.yaml +248 -0
  6. lyss/_bundled/adapters/mikrotik.yaml +292 -0
  7. lyss/_bundled/adapters/sonic.yaml +218 -0
  8. lyss/_bundled/data/firmware.yaml +147 -0
  9. lyss/_bundled/spec/lyss-spec.yaml +345 -0
  10. lyss/changes.py +123 -0
  11. lyss/collector.py +488 -0
  12. lyss/config.py +126 -0
  13. lyss/discovery.py +277 -0
  14. lyss/engine.py +505 -0
  15. lyss/events.py +130 -0
  16. lyss/firmware.py +304 -0
  17. lyss/history.py +159 -0
  18. lyss/inventory.py +220 -0
  19. lyss/notify.py +145 -0
  20. lyss/oxidized.py +125 -0
  21. lyss/remediate.py +450 -0
  22. lyss/report.py +93 -0
  23. lyss/scan.py +110 -0
  24. lyss/scheduler.py +81 -0
  25. lyss/sim/__init__.py +10 -0
  26. lyss/sim/config.py +200 -0
  27. lyss/sim/demo.py +147 -0
  28. lyss/sim/lab.py +203 -0
  29. lyss/sim/labs/demo/demo-acc-01.cfg +143 -0
  30. lyss/sim/labs/demo/demo-acc-02.cfg +136 -0
  31. lyss/sim/labs/demo/demo-core.cfg +304 -0
  32. lyss/sim/labs/demo/demo-crs-01.cfg +18 -0
  33. lyss/sim/labs/demo/demo-crs-01.state +90 -0
  34. lyss/sim/labs/demo/demo-hp-01.cfg +20 -0
  35. lyss/sim/labs/demo/demo-leaf-01.cfg +41 -0
  36. lyss/sim/labs/demo/demo-leaf-01.state +53 -0
  37. lyss/sim/labs/demo/demo-sb-01.cfg +118 -0
  38. lyss/sim/labs/demo/demo-sb-02.cfg +99 -0
  39. lyss/sim/labs/demo/lab.yaml +144 -0
  40. lyss/sim/render.py +445 -0
  41. lyss/sim/server.py +437 -0
  42. lyss/sites.py +162 -0
  43. lyss/topology.py +303 -0
  44. lyss/vault.py +109 -0
  45. lyss/web/__init__.py +2 -0
  46. lyss/web/app.py +996 -0
  47. lyss/web/static/app.js +139 -0
  48. lyss/web/static/favicon.svg +9 -0
  49. lyss/web/static/firmware.js +19 -0
  50. lyss/web/static/maroc.svg +25 -0
  51. lyss/web/static/remediation.js +57 -0
  52. lyss/web/static/style.css +382 -0
  53. lyss/web/static/topology.js +290 -0
  54. lyss/web/static/zellige.svg +18 -0
  55. lyss/web/templates/_hero.html +14 -0
  56. lyss/web/templates/_macros.html +16 -0
  57. lyss/web/templates/backup_diff.html +22 -0
  58. lyss/web/templates/base.html +47 -0
  59. lyss/web/templates/dashboard.html +163 -0
  60. lyss/web/templates/device.html +147 -0
  61. lyss/web/templates/device_form.html +78 -0
  62. lyss/web/templates/discovery.html +110 -0
  63. lyss/web/templates/firmware.html +79 -0
  64. lyss/web/templates/login.html +20 -0
  65. lyss/web/templates/matrix.html +48 -0
  66. lyss/web/templates/remediation.html +96 -0
  67. lyss/web/templates/report.html +154 -0
  68. lyss/web/templates/settings.html +270 -0
  69. lyss/web/templates/setup.html +22 -0
  70. lyss/web/templates/topology.html +45 -0
  71. lyss-0.8.0.dist-info/METADATA +210 -0
  72. lyss-0.8.0.dist-info/RECORD +79 -0
  73. lyss-0.8.0.dist-info/WHEEL +5 -0
  74. lyss-0.8.0.dist-info/entry_points.txt +2 -0
  75. lyss-0.8.0.dist-info/licenses/LICENSE +661 -0
  76. lyss-0.8.0.dist-info/licenses/LICENSE-SPEC +396 -0
  77. lyss-0.8.0.dist-info/licenses/NOTICE +43 -0
  78. lyss-0.8.0.dist-info/licenses/TRADEMARKS.md +32 -0
  79. 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