Files
RouterOS/RouterOSAssistantTests/RouterOSCliParserTests.swift
KayandClaude Sonnet 5 800b48406b Fix live exploitierte RouterOS-CLI-Injection + Parser-Datenverlust
Deep-Dive-Gegencheck mit echtem Exploit-Nachweis gegen den Testrouter:

- RouterOSCommand.cliLine quotete Werte nur bei Leerzeichen und escapte
  eingebettete Anführungszeichen nie. Ein Kommentar wie test" ; :log
  warning "X schloss das Quoting vorzeitig und ließ RouterOS den Rest
  als zweiten Befehl ausführen. Live exploitiert (injizierter
  script,warning-Log-Eintrag) und live als behoben bestätigt. Betraf
  jede Schreiboperation über SSH - auf dem aktuellen Testrouter ist
  www-ssl deaktiviert, REST also unerreichbar, der Bug war aktiv.
- RouterOSCliParser.keyValues nahm an, print terse quote mehrwortige
  Werte - live an zwei Menüs widerlegt (RouterOS 7.24.4 quotet dort
  nichts). Trunkierte jeden mehrwortigen Wert beim ersten Leerzeichen.
  Fix: Token-Scan statt Regex.
- SSHTransport.fetchFieldValues defensiv gegen dieselbe Injection-Klasse
  gehärtet (aktuell nur hartkodiert aufgerufen, aber generische API).

2 neue Regressionstests, alle 101 Unit-Tests grün. Details in bugs.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 18:08:34 +02:00

107 lines
6.0 KiB
Swift

import XCTest
@testable import RouterOSAssistant
final class RouterOSCliParserTests: XCTestCase {
func testParseDeviceInfo() {
let raw = """
uptime: 1w2d3h4m5s
version: 7.24.2 (stable)
architecture-name: arm64
board-name: RB750Gr3
"""
let info = RouterOSCliParser.parseDeviceInfo(raw)
XCTAssertEqual(info.boardName, "RB750Gr3")
XCTAssertEqual(info.routerOSVersion, "7.24.2 (stable)")
XCTAssertEqual(info.architecture, "arm64")
XCTAssertEqual(info.uptime, "1w2d3h4m5s")
}
/// "running"/"disabled" are never present as `key=value` pairs in real `/interface print
/// terse` output — confirmed live (2026-09-15, hEX/RouterOS 7.x) against a router with a mix
/// of running, bridge-slave, and (synthetically added here) disabled ports:
/// 0 R name=ether1 ... (running)
/// 2 S name=ether3 ... (not running, bridge slave)
/// RouterOS encodes them as single-letter flags in a fixed-width column before the first
/// key=value pair instead ("X" = disabled, "R" = running, "S" = slave). An earlier version of
/// this parser (and this test) assumed invented `running=`/`disabled=` keys that never
/// actually appear — every interface's `running` silently read as `false` as a result.
func testParseInterfaces() {
let raw = """
0 R name=ether1 type=ether mac-address=AA:BB:CC:DD:EE:01
1 S name=ether2 type=ether mac-address=AA:BB:CC:DD:EE:02
2 X name=ether3 type=ether mac-address=AA:BB:CC:DD:EE:03
"""
let interfaces = RouterOSCliParser.parseInterfaces(raw)
XCTAssertEqual(interfaces.count, 3)
XCTAssertEqual(interfaces[0].name, "ether1")
XCTAssertTrue(interfaces[0].running)
XCTAssertFalse(interfaces[0].disabled)
XCTAssertEqual(interfaces[1].name, "ether2")
XCTAssertFalse(interfaces[1].running)
XCTAssertFalse(interfaces[1].disabled)
XCTAssertEqual(interfaces[2].name, "ether3")
XCTAssertFalse(interfaces[2].running)
XCTAssertTrue(interfaces[2].disabled)
}
/// The exact live output pasted by the user (2026-09-15, hEX) that surfaced the flag-column
/// bug — a mix of running/slave/plain ports plus the router's own bridge and loopback.
func testParseInterfacesMatchesLiveHexOutput() {
let raw = """
0 R name=ether1 default-name=ether1 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D5 last-link-up-time=2026-09-15 20:19:19 link-downs=0
1 RS name=ether2 default-name=ether2 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D6 last-link-up-time=2026-09-15 20:19:19 link-downs=0
2 S name=ether3 default-name=ether3 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D7 link-downs=0
3 R name=ether4 default-name=ether4 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D8 last-link-up-time=2026-09-15 20:19:19 link-downs=0
4 S name=ether5 default-name=ether5 type=ether mtu=1500 actual-mtu=1500 l2mtu=1596 max-l2mtu=2026 vrf=main mac-address=F4:1E:57:1B:37:D9 link-downs=0
5 R comment=defconf name=bridge type=bridge mtu=auto actual-mtu=1500 l2mtu=1596 vrf=main mac-address=F4:1E:57:1B:37:D6 last-link-up-time=2026-09-15 20:19:27 link-downs=0
6 R name=lo type=loopback mtu=65536 actual-mtu=65536 vrf=main mac-address=00:00:00:00:00:00 last-link-up-time=2026-09-15 20:19:15 link-downs=0
"""
let interfaces = RouterOSCliParser.parseInterfaces(raw)
XCTAssertEqual(interfaces.map(\.name), ["ether1", "ether2", "ether3", "ether4", "ether5", "bridge", "lo"])
XCTAssertEqual(interfaces.map(\.running), [true, true, false, true, false, true, true])
XCTAssertTrue(interfaces.allSatisfy { !$0.disabled })
}
/// Regression test for a real bug found on a physical hEX device: its SSH output didn't
/// split on bare "\n", collapsing every interface into one "line" whose repeated keys
/// (name=, type=, ...) overwrote each other, leaving only the last interface ("lo").
func testParseInterfacesHandlesCarriageReturnLineEndings() {
let raw = [
"0 R name=ether1 default-name=ether1 type=ether mtu=1500",
"1 RS name=ether2 default-name=ether2 type=ether mtu=1500",
"5 R comment=defconf name=bridge type=bridge mtu=auto",
"6 R name=lo type=loopback mtu=65536"
].joined(separator: "\r\n")
let interfaces = RouterOSCliParser.parseInterfaces(raw)
XCTAssertEqual(interfaces.count, 4)
XCTAssertEqual(interfaces.map(\.name), ["ether1", "ether2", "bridge", "lo"])
XCTAssertEqual(interfaces.map(\.type), ["ether", "ether", "bridge", "loopback"])
}
/// Regression test for bugs.md (2026-09-17, "erneuter Gegencheck"-Durchgang): RouterOS'
/// `print terse` does not quote multi-word values at all (live-confirmed, RouterOS 7.24.4,
/// two menus) — a comment "multi word test value" comes back as literal unquoted
/// `comment=multi word test value name=ether2 ...`. The old regex parser silently truncated
/// this to just "multi" and dropped "word test value" entirely; `keyValues` now token-scans
/// instead, only starting a new field on a token that itself looks like `key=...`.
func testParseGenericItemsPreservesUnquotedMultiWordValue() {
let raw = "1 RS comment=multi word test value name=ether2 default-name=ether2 mtu=1500"
let items = RouterOSCliParser.parseGenericItems(raw)
XCTAssertEqual(items.count, 1)
XCTAssertEqual(items[0].fields["comment"], "multi word test value")
XCTAssertEqual(items[0].fields["name"], "ether2")
XCTAssertEqual(items[0].fields["default-name"], "ether2")
XCTAssertEqual(items[0].fields["mtu"], "1500")
}
}