~3 hr. Extend Tool v0.2 to Tool v0.3: structured JSON run logs to disk; human-readable stderr summary; idempotency check via extended fingerprint; documented rollback path. v0.3 is what becomes v1.0 in the capstone.
Goal: ship sb6141_csrf/v03.py with three new properties beyond v0.2: per-run JSON log file at ~/.sb6141-csrf/runs/<ISO8601>.json; human-readable stderr summary; idempotency check that detects already-reset state; rollback documentation (and implementation stub for the SB6141 case).
Estimated time: ~3 hr.
Prerequisites: Week 7 lecture. Lab 5 complete (v0.2 working).
Authorization line: Required in lab notebook before any --no-dry-run invocation:
Lab 7 session, 2026-MM-DD HH:MM. Target SB6141 unit <serial>; scope per signed cohort
authorization; Tool v0.3 development + testing (expect ~3 factory-resets across the
session to verify idempotency check and rollback path).
Setup
mkdir -p ~/adv-101/lab-7/sb6141_csrf ~/adv-101/lab-7/tests
cd ~/adv-101/lab-7
cp ../lab-5/sb6141_csrf/v02.py sb6141_csrf/v03.py
cp ../lab-5/sb6141_csrf/__init__.py sb6141_csrf/__init__.py
cp ../lab-5/tests/test_v02.py tests/test_v03.py
Create the runs directory:
mkdir -p ~/.sb6141-csrf/runs
Part A: Add the structured JSON log writer (~45 min)
In sb6141_csrf/v03.py, add a function that writes a per-run JSON log file:
import json
import os
from datetime import datetime, timezone
from pathlib import Path
TOOL_VERSION = '0.3'
RUNS_DIR = Path.home() / '.sb6141-csrf' / 'runs'
def write_run_log(payload: dict) -> Path:
"""Write a per-run JSON log file. Returns the file path."""
RUNS_DIR.mkdir(parents=True, exist_ok=True)
iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H-%M-%SZ')
path = RUNS_DIR / f'{iso}.json'
with path.open('w', encoding='utf-8') as f:
json.dump(payload, f, indent=2, sort_keys=True)
return path
def build_run_payload(args, fingerprint_result: dict, action_result: dict | None, exit_code: int) -> dict:
"""Construct the per-run JSON payload."""
return {
'timestamp': datetime.now(timezone.utc).isoformat(),
'tool_version': TOOL_VERSION,
'target': args.target,
'action': ACTION_NAME,
'authorized_by': args.authorized_by,
'dry_run': args.dry_run,
'no_confirm': args.no_confirm,
'fingerprint': fingerprint_result,
'action_result': action_result,
'exit_code': exit_code,
}
Part B: Add the extended fingerprint for idempotency (~45 min)
The standard fingerprint (v0.1) detects SB6141. The extended fingerprint detects "SB6141 AT DEFAULT CONFIGURATION." Default state markers (firmware-specific; tune per your unit):
- Default Wi-Fi SSID present (the modem at factory-reset has a known default; the cohort can document the exact value)
- Default admin-password prompt visible OR absent (depending on firmware version)
- Default firmware-version banner unchanged (means no firmware-level config diff since reset)
DEFAULT_STATE_MARKERS = [
re.compile(r'Default\s*SSID', re.IGNORECASE),
re.compile(r'Default\s*Settings', re.IGNORECASE),
# Tune per your firmware:
# re.compile(r'<your unit-specific default marker>', re.IGNORECASE),
]
def fingerprint_with_state(target: str, timeout: float = 5.0) -> dict:
"""Probe + decide both 'is SB6141?' and 'is at default state?'."""
base = fingerprint(target, timeout=timeout)
if not base['match']:
return {**base, 'at_default_state': None}
# base matched; do a second probe to evaluate state markers
url = f'http://{target}/'
try:
resp = requests.get(url, timeout=timeout)
except requests.exceptions.RequestException as e:
return {**base, 'at_default_state': None,
'state_check_failed': str(e)}
state_hits = sum(1 for r in DEFAULT_STATE_MARKERS if r.search(resp.text))
return {**base, 'at_default_state': state_hits >= len(DEFAULT_STATE_MARKERS) // 2}
The state_hits >= len(DEFAULT_STATE_MARKERS) // 2 is a softer match than "all markers present"; firmware variation makes strict-all-markers brittle. Tune per cohort.
Part C: Wire idempotency into main() (~30 min)
Before the destructive action, check if it has already been applied. If yes, exit OK with a "already done" message:
EXIT_ALREADY_DONE = 5 # new exit code
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
# ... logging setup as in v02 ...
fp_full = fingerprint_with_state(args.target, timeout=args.timeout)
if not fp_full['match']:
log_path = write_run_log(build_run_payload(args, fp_full, None, EXIT_FINGERPRINT_MISMATCH))
print(f'Error: fingerprint mismatch on {args.target}: {fp_full["reason"]}', file=sys.stderr)
print(f'(Run log: {log_path})', file=sys.stderr)
return EXIT_FINGERPRINT_MISMATCH
print(f'Fingerprint OK: SB6141 at {args.target} (firmware {fp_full.get("firmware_hint", "unknown")})')
# Idempotency check
if fp_full.get('at_default_state'):
print('Target is already at default state; factory-reset is a no-op.')
print('(Use --rollback PATH or re-configure the modem if a fresh reset is intentional.)')
log_path = write_run_log(build_run_payload(args, fp_full, {'idempotent_skip': True}, EXIT_ALREADY_DONE))
print(f'(Run log: {log_path})', file=sys.stderr)
return EXIT_ALREADY_DONE
# Dry-run preview
# ... (as in v02) ...
if args.dry_run:
log_path = write_run_log(build_run_payload(args, fp_full, None, EXIT_OK))
print('Dry-run mode (default). Use --no-dry-run to execute.')
print(f'(Run log: {log_path})', file=sys.stderr)
return EXIT_OK
# ... (confirmation; execute; as in v02; followed by:)
action_result = do_csrf_reset(args.target, timeout=args.timeout)
exit_code = EXIT_OK if action_result['success'] else EXIT_RUNTIME_ERROR
log_path = write_run_log(build_run_payload(args, fp_full, action_result, exit_code))
print(f'(Run log: {log_path})', file=sys.stderr)
if action_result['success']:
print(f'Action complete (HTTP {action_result["status_code"]}). Modem will be reachable again in ~60 seconds.')
return EXIT_OK
print(f'Error: destructive request returned HTTP {action_result["status_code"]}', file=sys.stderr)
return EXIT_RUNTIME_ERROR
Add EXIT_ALREADY_DONE = 5 to the exit-code constants near the top of the file.
Part D: Document the rollback path (~30 min)
Add a --rollback subcommand stub (or argparse mutually-exclusive option):
parser.add_argument(
'--rollback',
metavar='RUN_LOG.json',
default=None,
help='roll back to the state captured in RUN_LOG.json (pre-action state must have been captured)',
)
parser.add_argument(
'--capture-pre-state',
action='store_true',
default=False,
help='capture the modem\'s pre-action state and embed it in the run log (required for rollback)',
)
The rollback handler reads the run-log JSON, extracts the pre-state record, and applies the captured configuration via the SB6141 admin endpoints. Stub implementation:
def do_rollback(rollback_path: Path, target: str, authorized_by: str, timeout: float) -> dict:
"""Apply the pre-action state captured in a prior run log.
This is a stub. The full implementation requires:
1. Pre-state capture (--capture-pre-state on the destructive run)
2. Per-config-field setter endpoints documented in the SB6141 admin interface
3. Per-field POST replay to restore the captured values
For Lab 7, this stub demonstrates the design pattern; a complete implementation
is a capstone-stretch exercise.
"""
with open(rollback_path, 'r') as f:
prior_run = json.load(f)
pre_state = prior_run.get('pre_state')
if not pre_state:
return {
'success': False,
'reason': 'rollback unavailable; prior run did not capture pre-state '
'(missing --capture-pre-state on the destructive invocation)',
}
# STUB: in a full implementation, replay each pre_state field via the SB6141 admin POST endpoints
return {
'success': False,
'reason': 'rollback implementation stub; pre-state captured but replay not implemented in Lab 7',
'pre_state_summary': {k: type(v).__name__ for k, v in pre_state.items()},
}
For Lab 7, the rollback is documented (the design is correct; the design pattern is teachable) but not fully implemented. The capstone (v1.0) may complete the implementation if the cohort has time.
Part E: Test (~30 min)
Run sequence (with authorization line in lab notebook first):
# 1. First dry-run; expect: dry-run preview + run-log file created
python3 -m sb6141_csrf.v03 --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)"
ls ~/.sb6141-csrf/runs/
cat ~/.sb6141-csrf/runs/<latest>.json # verify JSON structure
# 2. First actual execution; expect: fingerprint OK, not at default; reset; run-log
python3 -m sb6141_csrf.v03 --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)" --no-dry-run
# (type "factory-reset" at the prompt)
# 3. Wait ~60s for modem to come back; immediately re-run; expect: at default state; idempotent skip; exit 5
python3 -m sb6141_csrf.v03 --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)" --no-dry-run
# Expected: "Target is already at default state; factory-reset is a no-op."
# 4. Re-configure the modem manually (change Wi-Fi name); re-run; expect: now NOT at default; ready to reset again
# (interactive; configure via SB6141 admin UI)
python3 -m sb6141_csrf.v03 --target 192.168.100.1 --authorized-by "Alice (cohort 2026-A)" --no-dry-run
# Expected: not at default; proceeds to confirmation prompt
Verify each run produced a JSON file:
ls -la ~/.sb6141-csrf/runs/
Part F: Add unit tests for new behaviors (~20 min)
def test_write_run_log_creates_file(tmp_path, monkeypatch):
"""write_run_log writes a parseable JSON file."""
monkeypatch.setattr(v03, 'RUNS_DIR', tmp_path)
payload = {'timestamp': '2026-05-29T00:00:00+00:00', 'tool_version': '0.3', 'exit_code': 0}
path = v03.write_run_log(payload)
assert path.exists()
with open(path) as f:
data = json.load(f)
assert data['tool_version'] == '0.3'
def test_idempotency_skips_when_at_default(monkeypatch, tmp_path):
"""When fingerprint_with_state reports at_default_state, the tool exits EXIT_ALREADY_DONE."""
monkeypatch.setattr(v03, 'RUNS_DIR', tmp_path)
def fake_fingerprint_with_state(target, timeout=5.0):
return {'match': True, 'reason': '', 'firmware_hint': '7.5.0', 'at_default_state': True}
monkeypatch.setattr(v03, 'fingerprint_with_state', fake_fingerprint_with_state)
called = {'count': 0}
monkeypatch.setattr(v03, '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 = v03.main([
'--target', '1.2.3.4',
'--authorized-by', 'tester',
'--no-dry-run',
])
assert rc == v03.EXIT_ALREADY_DONE
assert called['count'] == 0 # destructive action skipped
Run:
python3 -m pytest tests/ -v
# All tests should pass
Part G: Commit + portfolio (~15 min)
cd ~/adv-101/lab-7
git add sb6141_csrf/v03.py tests/test_v03.py README.md
git commit -m "Lab 7: Tool v0.3 (JSON run logs + idempotency check + rollback stub; tests pass)"
cd ~/adv-101
cat >> lab-portfolio.md <<EOF
### Lab 7 session, 2026-MM-DD HH:MM
**Target:** SB6141 unit serial <X>
**Action:** Tool v0.3 development (JSON run log + idempotency + rollback documentation)
**Authorization basis:** per signed cohort authorization (counter-signed 2026-MM-DD)
**Session duration:** ~3h (3 factory-resets across the session)
**Artifacts produced:** lab-7/sb6141_csrf/v03.py; lab-7/tests/test_v03.py; ~/.sb6141-csrf/runs/*.json
**Incidents:** none; idempotency check correctly identified already-reset state on second consecutive run
EOF
git add lab-portfolio.md
git commit -m "Portfolio: Lab 7 session entry"
Expected output / artifact
~/adv-101/lab-7/ containing:
sb6141_csrf/v03.py(Tool v0.3: structured logging + idempotency + rollback stub)tests/test_v03.py(10+ tests; all pass)- Updated
README.mdreflecting v0.3 behaviors
Plus ~/.sb6141-csrf/runs/*.json (one file per run; the audit trail).
What's the failure mode?
- Operator deletes the runs directory. Audit trail loss. Defense: the academy can require the cohort to commit
~/.sb6141-csrf/runs/to the per-cohort Git repository; the academy maintains the canonical copy. - JSON log includes sensitive data. The current schema does not, but a careless future change could. Code review the schema before each tool release.
- Idempotency check false-positive. The state markers may report "at default" when the modem is in fact at a configuration that happens to look default. Two-marker discipline (require multiple markers) helps; documenting which markers are checked in the run log preserves verifiability.
- Rollback assumes pre-state was captured. The stub correctly fails when pre-state is missing; full implementation in capstone phase.
What would a reviewer ask?
- "Show me a run-log entry where the idempotency check fired. Walk me through how it decided 'already done.'"
- "Your rollback is a stub. Why a stub now? What would the full implementation require?"
- "Where do the JSON logs live? What happens if they fill the disk?"
Stretch (optional)
- Implement
--capture-pre-statefor one configuration field (e.g., Wi-Fi SSID). Reading the value from the SB6141 admin page; embedding in the run log; restoring via a POST request. - Add log rotation. Auto-prune runs older than 90 days; archive instead of delete; document the policy.
- JSON-schema-validate the run log. Use the
jsonschemalibrary; add a test that every run-log file validates against the schema. - Convert to
structlog. Refactor v0.3 to usestructloginstead of stdliblogging; compare ergonomics.
Lab 7 v0.1.