Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,19 @@ These are binding on this derivative work — see
time-of-use)`.
- **Quizzes**: full descriptive names in question stems (never "What causes
A01" — OWASP renumbers between releases); no free-text questions; no empty
answer options; questions nested under their `<quiz>` node.
answer options; questions nested under their `<quiz>` node; every question
carries a hint and an explanation, shown when a learner answers it wrong.
- **No auto-playing text**: content pages show all content at once (Plain Text,
or Bullets with `delaySecs="0"`).
- **Incident callouts** must be self-contained for learners who haven't read the
OWASP docs; incident references include hyperlinked sources.

## Progress and completion: what learners and the LMS see

- **In a quiz**: answering a question wrong brings up a hint (what to think
about, and which theme to revisit) and an explanation of the right answer.
Getting it right shows the usual confirmation and nothing more. See
[`docs/PROJECT_CONTEXT.md`](docs/PROJECT_CONTEXT.md#wrong-answer-help-hint--explanation-on-every-question).
- **In the course**: a header progress bar (percentage of pages opened, with a
marker per quiz), contents-page ticks that mean *opened* for content pages
and *submitted* for quizzes (an opened-but-unsubmitted quiz shows a
Expand Down
11 changes: 11 additions & 0 deletions docs/COURSE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ Model, version 1.2)** package with **LMS (Learning Management System)-reported s
releases.
3. **No free-text questions**: all quiz items are selection-based (single choice, multiple
response, true/false, matching, categorise).
4. **Wrong-answer help on every question**: each question carries a hint (what to think about
and which theme to revisit) and an explanation (why the right answer is right, and where
it helps, why the tempting distractor is not), shown only when the learner answered it
wrong. The hint must not give the answer away; neither may refer to an option by letter or
position, because the options shuffle per attempt. Both are held in the question's
`feedback` attribute — see `PROJECT_CONTEXT.md` § "Wrong-answer help". This text is read
on its own, so convention 1 does not carry across it: expand each acronym on first use
within the hint and within the explanation, even where it is already expanded on the page.

## Template / technical setup

Expand All @@ -44,6 +52,9 @@ Model, version 1.2)** package with **LMS (Learning Management System)-reported s
pages done/still to visit, quiz results, weighted score and the status reported to the LMS.
- **Option order**: every quiz question sets `answerOrder="random"` so the answer options
shuffle on each attempt (Nottingham per-question "Answer Order" property).
- **Wrong-answer help**: every quiz question sets `feedback` (the per-question "General
Feedback" property) to a hint plus an explanation; the root script clears it again when the
answer was right, so only a learner who got it wrong is shown it.
- **Question types used** (all selection-based, no free text): Multiple Choice (single),
Multiple Response (select all), True/False, Matching, Categorise.
- Editor opened directly at `/edit.php?template_id=<id>`; **Publish** before export.
Expand Down
76 changes: 76 additions & 0 deletions docs/COURSE_VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,3 +278,79 @@ other tracking attrs), export a SCORM package, and grep the exported
`template.xml` for the checklist values in `PROJECT_CONTEXT.md`. Then walk a
quiz end-to-end (answer → Check → Next → complete → Restart) and confirm
options reorder between attempts.

## Wrong-answer help: hint + explanation on every question (post-0.0.6)

Every `<question>` gained a `feedback` attribute (the per-question *General
Feedback* field) holding a hint and an explanation, and part 6 of
`source/scorm_progress.js` clears that feedback slot again when the answer was
right, so only a learner who got the question wrong is shown it. See
`PROJECT_CONTEXT.md` § "Wrong-answer help" for the mechanism and why the
per-option `feedback` field was not used.

### Local checks (run against `source/data.xml`; XOT re-export still required per convention 9)

- 45 `<question>` elements, 45 with a `feedback` attribute; each unescapes to
`<p><strong>Hint:</strong> …</p><p><strong>Why:</strong> …</p>`, the same
double-escaped shape as the existing `prompt`/`text` values.
- No hint or explanation names an option by letter or position (options
shuffle per attempt), and no question-level `feedback` gives the answer away
in its hint.
- Option `feedback` attributes are still empty ×161, so the SCORM interaction
records (`XTExitInteraction` is passed the *option* feedback) are unchanged.
- `trackingWeight`, `trackingMode`, `trackingPassed`, `judge="true"` ×8,
`answerOrder="random"` ×45, `delaySecs="0"` ×27 unchanged.
- `python3 tools/sync_root_script.py --check` passes; the compiled root script
extracted from `data.xml` passes `node --check`.
- `tools/render_preview.py` renders 44 pages without error, with the help text
under each question for review.
- `source/data.xml` and `source/preview.xml` are byte-identical.

### Review pass (three reviewers, post-CI)

The first green build was reviewed for runtime correctness, content and XML
integrity, and fidelity to the OWASP sources. The mitigations were the weakest
part and four were out of date against the live `top10.owasp.org/2025` pages:

- Password storage named bcrypt, which A04 now mentions only for legacy
systems. It now names an adaptive salted hash with a work factor (Argon2,
yescrypt, scrypt, PBKDF2-HMAC-SHA-512).
- Mishandling of exceptional conditions carried Security Misconfiguration's
advice about keeping detail out of errors. It now teaches what A10 leads
with: catching at source, a global handler, full rollback (its own term is
"failing closed") and rate limits.
- Supply chain recommended reproducible builds, a phrase absent from A03. It
now names official sources, deliberately chosen versions and hardened CI/CD.
- Insecure design kept "usage limits", a bullet dropped from A06, in place of
the secure development lifecycle and secure design patterns it now leads
with.

Four content problems were fixed as well: broken access control was described
as human-only, where OWASP says users, which is the whole point for agent and
non-human identities; two adjacent final-quiz hints stated opposite
classification rules, and both now point at the stem's own disambiguator; one
hint argued against its own keyed answer; and one leaked its answer by word
stem.

Two latent runtime defects were corrected. `helpSlot` now follows the engine's
`feedbackPos` rule exactly, because an empty value leaves no G slot and the
previous code would have deleted the verdict line. The slot is also cleared
through jQuery, so the model's slide-in animation cannot animate a block that
has already been emptied.

Two tests could pass while the feature was broken. The every-question walk
asserted only that its list of failures was empty, so it would have passed if
the walk had matched nothing; it now asserts it inspected all 45.
`"correctFeedback"` is a Python substring of `"incorrectFeedback"`, so that
assertion held whatever the grading outcome; it is now an exact match. The
wrong-answer test also continues onto a second quiz page, which is the case
the re-wrap on `x_pageLoaded` exists for.

**Pending (convention 9 — MANDATORY before merge)**: the PR's CI job builds the
package in a real XOT container and runs `tests/test_scorm_tracking.py`
(including `test_wrong_answer_gets_a_hint_and_an_explanation` and
`test_every_question_carries_wrong_answer_help`) — that is the import/export
gate. Still to do by hand: open the HTML5 editor and confirm a Publish
round-trip preserves the new `feedback` values, and walk a quiz in `play.php`
answering one question wrong and one right, confirming the help appears only
on the wrong one and that the layout holds on a narrow screen.
68 changes: 65 additions & 3 deletions docs/PROJECT_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ with agents and building agents.
- **All 20 OWASP items** present: A01–A10 (classic) + ASI01–ASI10 (agentic),
each with its own page, interleaved by theme.
- **Quizzes**: 7 theme quizzes + 1 final quiz, all real `<quiz>` nodes with
nested `<question>` children. **45 scored questions total** (23 theme +
18 final + 4 … see `COURSE_VERIFICATION.md`). Every question has ≥2 options
nested `<question>` children. **45 scored questions total** (27 theme +
18 final; see `COURSE_VERIFICATION.md`). Every question has ≥2 options
and ≥1 `correct="true"`; **no empty option nodes**.
- **Option order is randomised per attempt**: every `<question>` carries
`answerOrder="random"` (the Nottingham wizard's per-question "Answer Order"
Expand Down Expand Up @@ -148,6 +148,15 @@ These were agreed with the course owner and must be preserved:
computed from the shuffled array, so SCORM tracking is unaffected. When
adding a new question, set `answerOrder="random"` on it. Keep `data.xml`
and `preview.xml` byte-identical.
11. **Wrong-answer help on every question**: each `<question>` carries a
`feedback` attribute holding a hint and an explanation, shown
only to a learner who answered it wrong (see "Wrong-answer help" below).
A new question needs one; the hint must not give the answer away, and
neither field may refer to an option by letter or position, because
`answerOrder="random"` shuffles them. Wrong-answer help is read on its
own, so convention 2 does not carry across it: expand each acronym on
first use **within the hint and within the explanation**, even where the
same acronym is already expanded elsewhere on that page.

## Known deviations (acknowledged, not to "fix" without asking)

Expand Down Expand Up @@ -480,6 +489,58 @@ Tests: `test_status_panel_tells_the_learner_what_is_left`,
`test_header_progress_bar_counts_viewed_pages`, and the panel assertions at the
end of `test_full_walkthrough_reports_passed`.

## Wrong-answer help (hint + explanation on every question)

A learner who picked the wrong option used to see nothing but *"Your answer is
incorrect"*, so there was nothing to learn from and nothing to revise before
the restart. All 45 questions now carry a hint (what to think about, and which
theme to revisit) and an explanation (why the right answer is right, and where
useful why the tempting distractor is not), shown only when the answer was
wrong.

The text lives in the question's own `feedback` attribute, the quiz wizard's
*General Feedback* field (`quiz.xwd`), so the XOT editor exposes it as a normal
per-question text area and round-trips it. The value carries the same
double-escaped HTML as `prompt` and `text`:

```xml
<question ... feedback="&lt;p&gt;&lt;strong&gt;Hint:&lt;/strong&gt; … &lt;/p&gt;&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; … &lt;/p&gt;&#10;">
```

It needs the root script because `models_html5/quiz.html` renders that
attribute on every submitted answer, right or wrong. After grading, the model
fills three fixed slots (`#topFeedback`, `#middleFeedback`, `#bottomFeedback`)
in the order the question's `feedbackPos` gives, one letter per slot: `G` is
the question's own feedback, `A` the selected option's, `C` the right/wrong
line. That defaults to `GAC`, so ours lands in `#topFeedback`. Part 6 of
`source/scorm_progress.js` wraps `quiz.showFeedBackandTrackResults`, re-wrapped
from `x_pageLoaded` because the model object is rebuilt for every quiz page,
and empties that slot again when `quiz.myProgress[quiz.currentQ] === true`.

If upstream ever renames that call the wrapper does nothing and the help shows
on correct answers as well, which loses nothing. A change to the slot order
would be worse, because the wrapper would clear whatever else landed in that
slot. So `helpSlot` follows the engine's own `feedbackPos` rule rather than
assuming the first slot: an *absent* attribute means `GAC`, while an empty one
leaves the engine no slot letters at all, so there is no G slot to clear.

The per-option `feedback` field was the other candidate and was not used. It
says nothing to a learner who gets a *Multiple Answer* question wrong by
under-selecting, because every option they ticked was a correct one, and it
would repeat the same paragraph on each wrong option.

Tracking is unaffected. `XTExitInteraction` is handed `trackData.feedback`,
which is built from the *option* `feedback` values, and those are all still
empty, so the SCORM interaction records are untouched.

Tests: `test_wrong_answer_gets_a_hint_and_an_explanation` (a wrong answer is
given both, a correct one is not, across two quiz pages) and
`test_every_question_carries_wrong_answer_help` (all 45 questions have it, so a
question added later cannot silently ship without one). Reviewers can read the
text in the PR preview, which `tools/render_preview.py` renders under each
question. A question added later needs a `feedback` value in the same shape, or
that second test fails.

## Build version stamping (`{{BUILD_VERSION}}` placeholder)

So an operator can tell **which version is running** in an LMS, the SCORM
Expand Down Expand Up @@ -626,8 +687,9 @@ unzip -p Secure_code_development_scorm.zip template.xml | grep -oE 'trackingPass
```bash
unzip -p Secure_code_development_scorm.zip template.xml > /tmp/c.xml
grep -c "Trial MCQ" /tmp/c.xml # 0
grep -oE '<question' /tmp/c.xml | wc -l # 45 (23 theme + 18 final + 4 …)
grep -oE '<question' /tmp/c.xml | wc -l # 45 (27 theme + 18 final)
grep -oE 'answerOrder="random"' /tmp/c.xml | wc -l # 45 (every question shuffles options per attempt)
grep -oE 'Hint:' /tmp/c.xml | wc -l # 45 (wrong-answer help on every question)
grep -oE 'trackingPassed="[^"]*"' /tmp/c.xml # 80%
grep -oE 'judge="true"' /tmp/c.xml | wc -l # 8
grep -oE 'trackingWeight="[0-9]+"' /tmp/c.xml # 7x "1" + 1x "21"
Expand Down
18 changes: 9 additions & 9 deletions source/data.xml

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions source/preview.xml

Large diffs are not rendered by default.

98 changes: 98 additions & 0 deletions source/scorm_progress.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@
* page) with the required-page count, the pages still to do, each quiz
* result, the weighted score and the exact status the LMS holds.
*
* 6. shows the hint and explanation authored on a question only to a learner
* who got that question wrong. The text lives in the question's own
* feedback attribute (the quiz wizard's "General Feedback" field), which
* models_html5/quiz.html renders into a feedback slot on every submitted
* answer, right or wrong. This script clears that slot again when the
* answer was correct, so the learner who needs the help sees it and the
* learner who does not is left with the plain "correct" message. If the
* engine renames that call the wrapper does nothing and the feedback
* simply shows on correct answers too, which loses nothing; a change to
* the slot order would be worse, because the wrapper would clear whatever
* else landed there, so helpSlot follows the engine's own rule for
* feedbackPos rather than assuming the first slot.
*
* CONSTRAINT: the compiled one-liner is stored raw in an XML attribute, so it
* must not contain < > & or " characters (tools/sync_root_script.py enforces
* this). Use === / !== / indexOf() instead of ordering comparisons, nested ifs
Expand Down Expand Up @@ -649,11 +662,96 @@
var orig = window.x_pageLoaded;
window.x_pageLoaded = function () {
var result = orig.apply(this, arguments);
try { wrapQuizFeedback(); } catch (e) { }
refreshUi();
return result;
};
}

/* ---- wrong-answer help: the hint and explanation on a question ---- */

var FEEDBACK_SLOTS = ['topFeedback', 'middleFeedback', 'bottomFeedback'];

/* quiz.currentQ indexes quiz.questions, which holds the position of each
question within the quiz page, in the order this attempt asks them. */
function questionNode() {
if (typeof quiz === 'undefined') { return null; }
if (!quiz) { return null; }
if (!isArray(quiz.questions)) { return null; }
var pos = quiz.questions[quiz.currentQ];
if (typeof pos !== 'number') { return null; }
if (typeof jQuery !== 'function') { return null; }
if (typeof x_currentPageXML === 'undefined') { return null; }
if (!x_currentPageXML) { return null; }
var node = jQuery(x_currentPageXML).children()[pos];
if (!node) { return null; }
return node;
}

/* The model fills three fixed slots in the order the question's
feedbackPos gives, one letter per slot: G is the question's own
feedback attribute, A the selected option's, C the right/wrong line.
No question here sets feedbackPos, so G lands in the first slot. */
function helpSlot(node) {
var order = 'GAC';
var attr = node.getAttribute('feedbackPos');
/* Only an absent attribute means GAC. The engine spreads the value it
finds, so an empty one leaves it no slot letters at all and puts the
right/wrong line in all three: G is then nowhere, and clearing the
first slot would delete the verdict instead. */
if (typeof attr === 'string') { order = attr; }
var pos = order.indexOf('G');
var id = FEEDBACK_SLOTS[pos];
if (typeof id !== 'string') { return null; }
return document.getElementById(id);
}

/* myProgress holds each question's graded result, and currentQ still
names the question just submitted until Next is pressed. */
function answerWasRight() {
if (typeof quiz === 'undefined') { return false; }
if (!quiz) { return false; }
if (!isArray(quiz.myProgress)) { return false; }
return quiz.myProgress[quiz.currentQ] === true;
}

/* Where the panel is centred vertically the model slides the feedback in,
and jQuery measures the height it is animating to while the text is
still there. Emptying the element after that would animate a blank gap
open and snap it shut, so finish the animation and hide it instead. */
function clearSlot(slot) {
if (typeof jQuery === 'function') {
jQuery(slot).stop(true, true).empty().hide();
return;
}
slot.textContent = '';
}

function hideHelpWhenRight() {
if (!answerWasRight()) { return; }
var node = questionNode();
if (!node) { return; }
var slot = helpSlot(node);
if (!slot) { return; }
clearSlot(slot);
}

/* The model object is built afresh for every quiz page, so this runs from
x_pageLoaded rather than once at startup. */
function wrapQuizFeedback() {
if (typeof quiz === 'undefined') { return; }
if (!quiz) { return; }
if (quiz.__xtHelp) { return; }
if (typeof quiz.showFeedBackandTrackResults !== 'function') { return; }
quiz.__xtHelp = true;
var orig = quiz.showFeedBackandTrackResults;
quiz.showFeedBackandTrackResults = function () {
var result = orig.apply(this, arguments);
try { hideHelpWhenRight(); } catch (e) { }
return result;
};
}

/* the quiz results screen is the only caller of XTSetPageScore */
function wrapPageScore() {
if (typeof window.XTSetPageScore !== 'function') { return; }
Expand Down
Loading