Quiz archive report
quiz_archive
Quiz archive is a mod_quiz report sub-plugin (quiz_archive) that renders all students' quiz attempts on a single printable page. Its primary purpose is to give teachers a "print / export as PDF" view of every attempt at once. The plugin has no database tables of its own, stores no personal data, and simply extends the mod_quiz\local\reports\attempts_report base class, reusing the standard quiz review display machinery (display_options, question_engine rendering) to produce a single long report.
The plugin is small, focused, and mostly follows Moodle conventions: it uses parameterised $DB queries, the formslib-derived options form (which carries sesskey automatically), a proper null_provider privacy implementation, and it gates the report behind mod/quiz:grade and a call to the parent init() / print_header_and_tabs().
The principal concern is that quizreportgetstudentandattempts() selects all attempts in the quiz without honouring group separation — in SEPARATEGROUPS mode a teacher who lacks moodle/site:accessallgroups will be shown attempts (names, answers, grades, feedback) belonging to students in other groups, bypassing the group control that the parent attempts_report::init() is explicitly designed to enforce. This is an authenticated, teacher-only information disclosure, which constrains the blast radius considerably.
Secondary issues are code-quality: archive_table.php is entirely dead code, referencing strings from the unrelated feedback component and require_once-ing attemptsreport_table.php which was removed in Moodle 5.1; $plugin->requires is 2017110800 (roughly Moodle 3.4) while the README and $plugin->supported claim a minimum of 3.9; and quiz_archive_options::__construct() overrides the parent constructor without chaining it, skipping the initialisation of usercanseegrades.
quiz_archive is a small mod_quiz report sub-plugin. Its single real job is to render every student's quiz attempt on one page so teachers can print or save an archive. The review found:
- 1 medium finding — the attempts query ignores group separation, so a teacher without
moodle/site:accessallgroupscan see attempts from other groups inSEPARATEGROUPSmode. - 3 low findings — dead
archive_table.phpfile (with arequire_oncefor a Moodle 5.1-removed file and strings borrowed frommod_feedback);$plugin->requiresfar older than the supported minimum; bundled Behat steps reference a removed core file path only in a never-taken branch. - 1 info finding —
quiz_archive_options::__constructdoes not chain to the parent constructor.
Security fundamentals are otherwise in good shape: $DB is used with named placeholders, the options come from a moodleform (automatic sesskey), require_capability('mod/quiz:grade', $context) gates the report, output goes through format_string / fullname / html_writer / Moodle renderers, and no filesystem, shell, or direct-DB escapes are present.
Findings
quiz_archive_report::quizreportgetstudentandattempts() returns every quiz_attempts row for the current quiz (excluding previews) without any filter on group membership. The parent mod_quiz\local\reports\attempts_report::init() explicitly returns $studentsjoins, $groupstudentsjoins, and $allowedjoins derived from get_enrolled_with_capabilities_join() for exactly this purpose, and report_base::get_current_group() degrades to NO_GROUPS_ALLOWED when the user lacks moodle/site:accessallgroups in SEPARATEGROUPS mode.
This plugin's display() calls $this->init(...) but discards all four return values, and the hand-rolled SQL does not join to groups_members, does not filter by enrolment, and does not consult the current group at all. As a result, in SEPARATEGROUPS mode a teacher (archetype teacher) who has mod/quiz:grade but not moodle/site:accessallgroups — the common split between the non-editing teacher and editing teacher roles — is shown the full archive (names, question text, responses, marks, manual comments, overall feedback) for students in groups they are not a member of.
Compare with quiz_overview_report::display() which forwards $allowedjoins to $table->setup_sql_queries($allowedjoins) so the visible rows respect the same group rule.
Medium risk. The attack requires the mod/quiz:grade capability, which is only granted to teacher-class roles, so this is not exploitable by students or unauthenticated users. However, the bypass is unconditional for any SEPARATEGROUPS quiz once those preconditions are met, and the disclosed material (free-text answers, teacher comments, grades) is sensitive personal data. The finding is downgraded from high because (a) an elevated role is required and (b) the exposure is read-only — there is no path to data modification or privilege escalation.
report_base::get_current_group() is already written to force NO_GROUPS_ALLOWED when SEPARATEGROUPS is active and the user lacks moodle/site:accessallgroups, and attempts_report::get_students_joins() builds the matching SQL joins. The plugin reuses the parent's init() (so the joins are computed), then simply ignores them and runs its own unfiltered SELECT from {quiz_attempts}. The output is rendered using the normal quiz review machinery, so every piece of data a student supplied to the quiz is exposed — free-text responses, uploaded essays, attachment content and overall feedback included.
- Create a course with two groups (A, B) and force group mode
Separate groupson the quiz activity. - Enrol
teacher_awith the non-editingteacherrole (which hasmod/quiz:gradebut notmoodle/site:accessallgroups) and add them only to group A. - Enrol
student_ain group A andstudent_bin group B; let each submit a quiz attempt. - Log in as
teacher_aand visit/mod/quiz/report.php?id=<cmid>&mode=archive. - The report renders
student_b's full attempt (name, answers, marks, feedback) even thoughteacher_ais not in group B.
$this->init('archive', 'quiz_archive_settings_form', $quiz, $cm, $course);
Capture the joins returned by init() so they can be applied to the attempts query, and honour NO_GROUPS_ALLOWED:
[$currentgroup, $studentsjoins, $groupstudentsjoins, $allowedjoins] =
$this->init('archive', 'quiz_archive_settings_form', $quiz, $cm, $course);
protected function quizreportgetstudentandattempts($quiz) {
global $DB;
// Construct the SQL.
$sql = "SELECT DISTINCT quiza.id attemptid, u.id userid, u.firstname, u.lastname FROM {user} u " .
"LEFT JOIN {quiz_attempts} quiza " .
"ON quiza.userid = u.id WHERE quiza.quiz = :quizid AND quiza.preview = 0 ORDER BY u.lastname ASC, u.firstname ASC";
$params = ['quizid' => $this->quizobj->id];
$results = $DB->get_records_sql($sql, $params);
Restrict the query with the joins returned from init(). For example, accept $allowedjoins as an argument and inline its SQL fragments:
protected function quizreportgetstudentandattempts($quiz, \core\dml\sql_join $allowedjoins) {
global $DB;
if (empty($allowedjoins->joins)) {
// No user is visible to the current teacher - bail out.
return [];
}
$sql = "SELECT DISTINCT quiza.id AS attemptid, u.id AS userid, u.firstname, u.lastname
FROM {user} u
{$allowedjoins->joins}
JOIN {quiz_attempts} quiza ON quiza.userid = u.id
WHERE {$allowedjoins->wheres}
AND quiza.quiz = :quizid
AND quiza.preview = 0
ORDER BY u.lastname ASC, u.firstname ASC";
$params = $allowedjoins->params + ['quizid' => $this->quizobj->id];
return $DB->get_records_sql($sql, $params);
}
Also handle the NO_GROUPS_ALLOWED case returned by get_current_group() by displaying the standard notingroup notification and returning.
archive_table.php defines quiz_archive_table extends flexible_table but the class is never referenced anywhere in the plugin (verified by grepping the repository — the only occurrence is the class declaration itself). The file still causes two kinds of problems:
- Its
require_once($CFG->dirroot . '/mod/quiz/report/attemptsreport_table.php');targets a file that was removed in Moodle 5.1 (mod/quiz/UPGRADING.mdlistsmod/quiz/report/attemptsreport_table.phpunder final deprecations). Any attempt to include this file on a 5.1+ site will fatal-error with "Failed opening required...". Because the file is unused today this is latent, but it is a trap for any future refactor that decides to pull the class in. - The class was clearly copy-pasted from
mod_feedback: the columns are namedtemplate, it usesget_string('template', 'feedback'),get_string('no_templates_available_yet', 'feedback'),get_string('delete_template', 'feedback')andget_string('confirmdeletetemplate', 'feedback'), it renders at/deleteicon labelled with a feedback-template delete string, and builds URLs withdeletetempl— none of which correspond to anything the quiz archive report does.
The file should simply be deleted.
Low risk. No security impact — the file is never executed. The risk is forward-compatibility churn (the require_once will fatally fail the day anybody does include it on Moodle 5.1+) and maintenance confusion (the class looks authoritative but has nothing to do with quiz archives).
The plugin's active code paths (report.php, archive_form.php, archive_options.php) have no require_once or new on this class. Even report.php only pulls in archive_options.php and archive_form.php. The presence of the file inflates the release ZIP and provides a wrong example if someone uses this plugin as a template.
require_once($CFG->dirroot . '/mod/quiz/report/attemptsreport_table.php');
Delete archive_table.php entirely. It is not referenced anywhere, its class body mixes feedback-module semantics with the quiz-archive namespace, and the require_once at the top points at a file that no longer exists in Moodle 5.1+.
class quiz_archive_table extends flexible_table {
Remove the file. The class is dead code copy-pasted from mod_feedback's template table and does not match anything the quiz archive report does.
version.php declares $plugin->requires = 2017110800, which is roughly the Moodle 3.4 release window (November 2017). However $plugin->supported = [39, 502] and the README.md both state that Moodle 3.9 is the minimum. The code backs up 3.9 as the true minimum — report.php calls normalize_version($CFG->release) from environmentlib.php and relies on the class-aliasing dance against \mod_quiz\local\reports\report_base / \mod_quiz_attempts_report_form that has no equivalent on a 3.4 site.
Because Moodle only refuses to install a plugin when $CFG->version is below $plugin->requires, an administrator running Moodle 3.4–3.8 can currently install this plugin; it will then fail at runtime when quiz_create_attempt_handling_errors, quiz_attempt_state, normalize_version, etc. do not behave as expected.
Low risk. No security impact. The effect is that someone running an out-of-support Moodle could install the plugin and then see fatal errors from calls the site cannot resolve. It is a release-hygiene issue, not an attack surface.
Moodle's plugin installer uses requires as the hard gate. supported is only used by admin/tool/installaddon to surface warnings; it does not block installation. Leaving requires pinned to 2017 means the advertised support window and the technically enforceable one disagree.
$plugin->version = 2026033000;
$plugin->requires = 2017110800;
$plugin->component = 'quiz_archive';
$plugin->maturity = MATURITY_STABLE;
$plugin->release = 'v5.2-r1';
$plugin->supported = [39, 502];
Raise requires to the version code of the earliest release listed in $plugin->supported. For Moodle 3.9 that is 2020061500:
$plugin->requires = 2020061500; // Moodle 3.9.
quiz_archive_options::__construct replaces the parent attempts_report_options::__construct wholesale and does not call parent::__construct(). The parent constructor does one thing the override does not — it sets $this->usercanseegrades = quiz_report_should_show_grades($quiz, context_module::instance($cm->id)).
The plugin happens not to consult $usercanseegrades anywhere (it re-derives grade visibility from $options->marks and quiz_has_grades($quiz) inside quiz_report_get_student_attempt), so today there is no functional regression. It is still worth noting because any future rebase onto parent code that reads $options->usercanseegrades (for example, when the attempts_report family is refactored again) will silently misbehave: $usercanseegrades will be null instead of a boolean.
Info. Purely a maintenance / contract issue. No user-visible impact on current Moodle releases.
The override was probably written to avoid the quiz_report_should_show_grades call in older Moodle. Today that function is stable and safe, and omitting the parent call is pure drift.
public function __construct($mode, $quiz, $cm, $course) {
$this->mode = $mode;
$this->quiz = $quiz;
$this->cm = $cm;
$this->course = $course;
}
Chain to the parent constructor so usercanseegrades gets populated correctly:
public function __construct($mode, $quiz, $cm, $course) {
parent::__construct($mode, $quiz, $cm, $course);
}
The whole override can then be deleted — there is no behaviour left to add.
The plugin ships Behat coverage (tests/behat/*.feature) but no PHPUnit tests under tests/*.php. The critical piece of logic that would benefit most from unit tests — the attempts query in quizreportgetstudentandattempts — is therefore only covered at the UI level, making regressions like the group-filtering gap in finding #1 easy to miss.
This is purely a best-practice observation; nothing in Moodle's rules mandates PHPUnit tests for a simple report plugin.
Info. No direct security impact — a suggestion to improve the long-term maintainability of the report.
The plugin directory layout contains only tests/behat/. A senior Moodle developer would expect at least a smoke-level PHPUnit test that drives quiz_archive_report::display() against a fixture quiz and asserts the shape of the attempts returned.
SQL discipline is good elsewhere. All $DB calls (get_records_sql, insert_record, execute, get_record) use named placeholders or property arrays — no string concatenation of user input into SQL. The single UPDATE in db/upgrade.php is a static literal.
sesskey / CSRF is covered by the framework. The only state the user submits is via quiz_archive_settings_form, which extends moodleform via attempts_report_options_form — the Moodle forms library validates sesskey automatically. There are no manual POST handlers.
Capability gating is correct. require_capability('mod/quiz:grade', $this->context) is called before any data is rendered, and the parent report dispatcher already calls require_login($course, false, $cm) in /mod/quiz/report.php before the plugin's display() runs.
Privacy API is declared correctly. quiz_archive\privacy\provider implements \core_privacy\local\metadata\null_provider and returns a real language string key — appropriate because the plugin has no DB tables of its own.
Output is escaped. Names flow through fullname(), the page title through format_string(), grades through html_writer::tag(), and rendered question content through the core question_engine renderer. No manual string interpolation into HTML was found.