mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Audit WebCRT commands from device stdout after tab completion.
ZTE tab redraws the input line via carriage return; parse the last prompt line from PTY stdout on Enter instead of stdin keystrokes. Also remove remaining ?? in AuditPage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a9c7e81793
commit
6ea14e6d5e
5 changed files with 110 additions and 13 deletions
|
|
@ -502,6 +502,49 @@ def normalize_audit_line(line: str) -> str:
|
||||||
return s.replace("\r", "").rstrip()
|
return s.replace("\r", "").rstrip()
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_ansi(text: str) -> str:
|
||||||
|
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\].*?\x07|\x1b.", "", str(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_prompt_command_line(line: str) -> bool:
|
||||||
|
"""True when line looks like ``hostname#command`` (non-empty command tail)."""
|
||||||
|
s = str(line or "").strip()
|
||||||
|
if not s:
|
||||||
|
return False
|
||||||
|
return bool(
|
||||||
|
re.match(
|
||||||
|
r"^(?:"
|
||||||
|
r"[\w.-]+(?:\([^)]+\))*[#>]\s*\S"
|
||||||
|
r"|<[^>]+>\s*\S"
|
||||||
|
r"|\[[^\]]+\]\s*\S"
|
||||||
|
r")",
|
||||||
|
s,
|
||||||
|
flags=re.I,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_last_prompt_command(text: str) -> str | None:
|
||||||
|
"""Last prompt+command line in PTY transcript (tab-complete redraw aware).
|
||||||
|
|
||||||
|
Network devices often refresh the current input with ``\\r`` after tab; the
|
||||||
|
final segment after the last carriage return is the ground truth for audit.
|
||||||
|
"""
|
||||||
|
s = _strip_ansi(text)
|
||||||
|
if not s.strip():
|
||||||
|
return None
|
||||||
|
# Prefer the tail after the last in-line refresh (tab completion / prompt rewrite).
|
||||||
|
tail = s.rsplit("\r", 1)[-1]
|
||||||
|
tail_line = tail.split("\n")[-1].rstrip()
|
||||||
|
if _is_prompt_command_line(tail_line):
|
||||||
|
return tail_line[:512]
|
||||||
|
for frag in reversed(re.split(r"[\r\n]+", s)):
|
||||||
|
line = frag.strip()
|
||||||
|
if line and _is_prompt_command_line(line):
|
||||||
|
return line[:512]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def feed_command_line_buffer(buf: str, data: str, *, max_line: int = 512) -> tuple[str, list[str]]:
|
def feed_command_line_buffer(buf: str, data: str, *, max_line: int = 512) -> tuple[str, list[str]]:
|
||||||
"""Accumulate stdin into completed command lines (Enter / CR / LF).
|
"""Accumulate stdin into completed command lines (Enter / CR / LF).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ class WebcrtSession:
|
||||||
_cmd_buf_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
_cmd_buf_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||||
_password_mode: bool = field(default=False, repr=False)
|
_password_mode: bool = field(default=False, repr=False)
|
||||||
_stdout_tail: str = field(default="", repr=False)
|
_stdout_tail: str = field(default="", repr=False)
|
||||||
|
_last_prompt_line: str = field(default="", repr=False)
|
||||||
|
|
||||||
def touch(self) -> None:
|
def touch(self) -> None:
|
||||||
self.last_activity = time.time()
|
self.last_activity = time.time()
|
||||||
|
|
@ -368,13 +369,19 @@ class WebcrtSession:
|
||||||
redacted = bool(self._password_mode)
|
redacted = bool(self._password_mode)
|
||||||
if redacted and (buf_lines or audit_line):
|
if redacted and (buf_lines or audit_line):
|
||||||
self._password_mode = False
|
self._password_mode = False
|
||||||
if audit_line is not None and ("\r" in text or "\n" in text):
|
if "\r" in text or "\n" in text:
|
||||||
from .webcrt_channel import normalize_audit_line
|
from .webcrt_channel import extract_last_prompt_command, normalize_audit_line
|
||||||
|
|
||||||
cmd = normalize_audit_line(audit_line)
|
cmd = extract_last_prompt_command(self._stdout_tail) or self._last_prompt_line
|
||||||
lines = [cmd] if cmd.strip() else []
|
if not cmd and audit_line:
|
||||||
|
cmd = normalize_audit_line(audit_line)
|
||||||
|
if cmd and str(cmd).strip():
|
||||||
|
lines = [cmd]
|
||||||
|
else:
|
||||||
|
lines = buf_lines
|
||||||
|
self._last_prompt_line = ""
|
||||||
else:
|
else:
|
||||||
lines = buf_lines
|
lines = []
|
||||||
for cmd in lines:
|
for cmd in lines:
|
||||||
if not str(cmd).strip():
|
if not str(cmd).strip():
|
||||||
continue
|
continue
|
||||||
|
|
@ -401,7 +408,12 @@ class WebcrtSession:
|
||||||
if not chunk:
|
if not chunk:
|
||||||
return
|
return
|
||||||
with self._cmd_buf_lock:
|
with self._cmd_buf_lock:
|
||||||
self._stdout_tail = (self._stdout_tail + chunk)[-4000:]
|
self._stdout_tail = (self._stdout_tail + chunk)[-8000:]
|
||||||
|
from .webcrt_channel import extract_last_prompt_command
|
||||||
|
|
||||||
|
line = extract_last_prompt_command(self._stdout_tail)
|
||||||
|
if line:
|
||||||
|
self._last_prompt_line = line
|
||||||
if looks_like_password_prompt(self._stdout_tail):
|
if looks_like_password_prompt(self._stdout_tail):
|
||||||
self._password_mode = True
|
self._password_mode = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from netx_api.webcrt_channel import (
|
||||||
feed_command_line_buffer,
|
feed_command_line_buffer,
|
||||||
looks_like_password_prompt,
|
looks_like_password_prompt,
|
||||||
normalize_audit_line,
|
normalize_audit_line,
|
||||||
|
extract_last_prompt_command,
|
||||||
)
|
)
|
||||||
from netx_api.webcrt_session_model import WebcrtSession
|
from netx_api.webcrt_session_model import WebcrtSession
|
||||||
|
|
||||||
|
|
@ -84,6 +85,34 @@ class NormalizeAuditLineTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractPromptCommandTests(unittest.TestCase):
|
||||||
|
def test_tab_redraw_after_carriage_return(self) -> None:
|
||||||
|
"""ZTE tab completion redraws the line with \\r — audit must use that tail."""
|
||||||
|
transcript = (
|
||||||
|
"AL5458-ACC-6120HS(config-if-loopback127)#ip ad"
|
||||||
|
"\rAL5458-ACC-6120HS(config-if-loopback127)#ip address "
|
||||||
|
"1.1.1.11 32"
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
extract_last_prompt_command(transcript),
|
||||||
|
"AL5458-ACC-6120HS(config-if-loopback127)#ip address 1.1.1.11 32",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_config_mode_interface(self) -> None:
|
||||||
|
transcript = "AL5458-ACC-6120HS(config)#interface loopback127"
|
||||||
|
self.assertEqual(
|
||||||
|
extract_last_prompt_command(transcript),
|
||||||
|
"AL5458-ACC-6120HS(config)#interface loopback127",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_show_partial_command(self) -> None:
|
||||||
|
transcript = "AL5458-ACC-6120HS(config-if-loopback127)#show th"
|
||||||
|
self.assertEqual(
|
||||||
|
extract_last_prompt_command(transcript),
|
||||||
|
"AL5458-ACC-6120HS(config-if-loopback127)#show th",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PasswordPromptTests(unittest.TestCase):
|
class PasswordPromptTests(unittest.TestCase):
|
||||||
def test_detects_password_prompt(self) -> None:
|
def test_detects_password_prompt(self) -> None:
|
||||||
self.assertTrue(looks_like_password_prompt("Password:"))
|
self.assertTrue(looks_like_password_prompt("Password:"))
|
||||||
|
|
@ -179,8 +208,8 @@ class SessionCommandAuditTests(unittest.TestCase):
|
||||||
self.assertFalse(kwargs["redacted"])
|
self.assertFalse(kwargs["redacted"])
|
||||||
|
|
||||||
@patch("netx_api.webcrt_session_model._audit")
|
@patch("netx_api.webcrt_session_model._audit")
|
||||||
def test_audit_line_overrides_stdin_buffer(self, mock_audit: MagicMock) -> None:
|
def test_stdout_prompt_line_wins_over_stdin_on_enter(self, mock_audit: MagicMock) -> None:
|
||||||
"""Tab-completed command: xterm-visible line wins over stdin keystrokes."""
|
"""Tab-completed command is in device stdout, not stdin keystrokes."""
|
||||||
conn = MagicMock()
|
conn = MagicMock()
|
||||||
conn.RETURN = "\n"
|
conn.RETURN = "\n"
|
||||||
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
|
conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"])
|
||||||
|
|
@ -188,7 +217,7 @@ class SessionCommandAuditTests(unittest.TestCase):
|
||||||
conn.write_channel = MagicMock()
|
conn.write_channel = MagicMock()
|
||||||
|
|
||||||
sess = WebcrtSession(
|
sess = WebcrtSession(
|
||||||
session_id="s-tab",
|
session_id="s-stdout",
|
||||||
ne_id="ne1",
|
ne_id="ne1",
|
||||||
ne_name="lab",
|
ne_name="lab",
|
||||||
ne_ip="1.2.3.4",
|
ne_ip="1.2.3.4",
|
||||||
|
|
@ -200,9 +229,16 @@ class SessionCommandAuditTests(unittest.TestCase):
|
||||||
owner_username="bob",
|
owner_username="bob",
|
||||||
conn=conn,
|
conn=conn,
|
||||||
)
|
)
|
||||||
sess.write_stdin("dis\t\r", audit_line="AL5458-ACC-6120HS#display version")
|
sess._note_stdout_for_audit(
|
||||||
|
"AL5458-ACC-6120HS(config-if-loopback127)#ip ad"
|
||||||
|
"\rAL5458-ACC-6120HS(config-if-loopback127)#ip address 1.1.1.11 32"
|
||||||
|
)
|
||||||
|
sess.write_stdin("\r", audit_line="AL5458-ACC-6120HS(config-if-loopback127)#ip ad")
|
||||||
kwargs = mock_audit.call_args.kwargs
|
kwargs = mock_audit.call_args.kwargs
|
||||||
self.assertEqual(kwargs["command"], "AL5458-ACC-6120HS#display version")
|
self.assertEqual(
|
||||||
|
kwargs["command"],
|
||||||
|
"AL5458-ACC-6120HS(config-if-loopback127)#ip address 1.1.1.11 32",
|
||||||
|
)
|
||||||
|
|
||||||
@patch("netx_api.webcrt_session_model._audit")
|
@patch("netx_api.webcrt_session_model._audit")
|
||||||
def test_password_mode_redacts_command(self, mock_audit: MagicMock) -> None:
|
def test_password_mode_redacts_command(self, mock_audit: MagicMock) -> None:
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,12 @@ export function normalizeAuditLine(line: string): string {
|
||||||
|
|
||||||
function currentCommandLine(term: Terminal): string {
|
function currentCommandLine(term: Terminal): string {
|
||||||
const buf = term.buffer.active;
|
const buf = term.buffer.active;
|
||||||
|
for (let y = buf.cursorY; y >= Math.max(0, buf.cursorY - 3); y -= 1) {
|
||||||
|
const line = buf.getLine(y);
|
||||||
|
if (!line) continue;
|
||||||
|
const text = normalizeAuditLine(line.translateToString(true));
|
||||||
|
if (/[#>\]]\s*\S/.test(text) || /#[^\s]/.test(text)) return text;
|
||||||
|
}
|
||||||
const line = buf.getLine(buf.cursorY);
|
const line = buf.getLine(buf.cursorY);
|
||||||
if (!line) return "";
|
if (!line) return "";
|
||||||
return normalizeAuditLine(line.translateToString(true));
|
return normalizeAuditLine(line.translateToString(true));
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,8 @@ export function auditSummary(
|
||||||
: neIds.length;
|
: neIds.length;
|
||||||
return t("audit.summary.neExecBatch", {
|
return t("audit.summary.neExecBatch", {
|
||||||
n: String(Number.isFinite(targetCount) ? targetCount : 0),
|
n: String(Number.isFinite(targetCount) ? targetCount : 0),
|
||||||
ok: String(d.ok_count ?? 0),
|
ok: String(d.ok_count != null ? Number(d.ok_count) : 0),
|
||||||
fail: String(d.fail_count ?? 0),
|
fail: String(d.fail_count != null ? Number(d.fail_count) : 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (action.startsWith("auth.")) {
|
if (action.startsWith("auth.")) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue