| id | course-secure-code-l03 | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| type | course | |||||||||||||||
| title | Lesson 3: Close one flaw class per turn | |||||||||||||||
| summary | Five bounded turns — search, attachments, secret key, passwords, sessions — each closing one weakness class with a mechanism, never a filter, and each proven by its tests and the replay. | |||||||||||||||
| lang | en-US | |||||||||||||||
| content_version | 1 | |||||||||||||||
| status | reviewed | |||||||||||||||
| reviewed_on | 2026-10-11 | |||||||||||||||
| hints |
|
The starter passes all sixteen tests through five bounded turns, each closing one weakness class, and you can explain why each fix holds for inputs the tests never try.
Ask an agent to "fix the security issues" and you get one large diff that mixes real fixes with filters, renames, and new helpers. You cannot tell which line stops which attack. One class per turn keeps each fix small enough to check against its mechanism from Lesson 2.
Start a fresh agent session and say:
"Work through TASK.md against starter/notes_app.py, one weakness class
per turn: find_notes, then read_attachment, then load_secret_key, then
hash_password and verify_password together, then dump_session and
load_session together. After each turn run python verify.py starter and
python starter/attack_replay.py scenario/team-notes, show me the diff,
and wait for me. Do not edit tests/, solution/, scenario/, or
attack_replay.py."
Expect the failing count and the replay to move like this:
| After turn | Failing tests | Replay (team-notes) |
|---|---|---|
| start | 11 | 0/9 |
find_notes |
9 | 2/9 |
read_attachment |
6 | 4/9 |
load_secret_key |
5 | 6/9 |
| passwords | 3 | 7/9 |
| sessions | 0 | 9/9 |
(Where the platform cannot create symlinks, the symlink test is skipped and the first two rows read 10 and 8.)
Hold each turn to its mechanism:
- Search: the SQL string is a constant; values go in the parameter
tuple. Reject any diff that escapes quotes or strips characters.
LIKEwith escaped wildcards is acceptable only if the escaping is complete;instravoids the question. - Attachments: resolve first, then check containment with
is_relative_to. Rejectif ".." in name— it misses symlinks and absolute paths, and it refuses legitimate names likenotes..txt. - Secret key: no fallback, ever. If the agent adds a "development default", it has reintroduced the flaw.
- Passwords:
secrets.token_bytes(16)for the salt,hashlib.pbkdf2_hmacfor the digest,hmac.compare_digestfor the check.verify_passwordreturns False for any format it did not write. - Sessions: remove
picklefrom the file entirely. Verify the signature before decoding; catch only the decoding errors you expect and turn them intoSecurityError.
After the sessions turn, run grep -n pickle starter/notes_app.py. It
should print nothing.
For the attachments fix, try two names the tests do not use, against
scenario/team-notes/attachments: ./agenda.txt (legitimate — it should
read) and agenda.txt/../../secrets.txt (it should be refused). Predict
each result before you run it, and write down whether the mechanism
handled both without a new line of code.
Run python verify.py. This checkpoint's code appears only when the
starter suite is green. You pass the lesson when you can answer:
- Which diff did you reject or narrow because it was a filter instead of a mechanism?
- Why does
test_replay_blocks_every_team_notes_attackonly pass on the last turn? - What would happen if
load_sessiondecoded the payload first and checked the signature second?
A green python verify.py starter, the replay printing blocked=9/9, the
five diffs, and your exercise result.