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>
107 lines
6.0 KiB
Swift
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")
|
|
}
|
|
}
|