Agent Detection Lite
local_agentdetect
local_agentdetect (Lite edition) is a Moodle local plugin that detects automated browser agents (Perplexity Comet, Claude in Chrome, Selenium, Playwright, Puppeteer, etc.) during quizzes. Detection works in two layers: a client-side AMD module collects browser fingerprint signals (navigator.webdriver, headless indicators, CDP artifacts, automation globals, known extension IDs, WebGL/canvas anomalies) and behavioural signals (mouse movement velocity, click-centre precision, keystroke cadence, scroll/click correlation, per-page mouse-to-action ratios), scores the session locally, and reports a compact summary to the server. The server stores signals in local_agentdetect_signals and aggregated per-user flags in local_agentdetect_flags, exposed through a system-level admin report (report.php), a course-level teacher report (coursereport.php), and quiz-page badges. JS is injected via the core\hook\output\before_footer_html_generation hook on quiz pages only. Signals reach the server through a Moodle external function (local_agentdetect_report_signals) and a low-overhead beacon.php endpoint for navigator.sendBeacon unload reports.
The plugin demonstrates mature security practices throughout: every page entry enforces require_login() and an appropriate require_capability(), every DB query uses $DB with named placeholders, the external functions go through validate_parameters() / validate_context(), the beacon endpoint validates confirm_sesskey() and re-checks contextid access against the submitted contextid, the privacy provider implements all required interfaces (including core_userlist_provider and metadata), backup/restore plugin classes are present, and capability definitions carry RISK_PERSONAL / RISK_CONFIG bitmasks. A signal_manager allowlist on both verdict and signaltype blocks the previously-reported stored-XSS path via client-controlled enum values, and the default render branches still escape unknown enum values as defence in depth. Test coverage exists for the security-critical paths exercised by the MOO-12 remediation (verdict/signaltype allowlist, get_user_flags participant filtering and NULL-context gating, course-report detail enrolment+group visibility, privacy provider). The only residual issues are low-severity: a NULL-context flag leak in the course report summary query that is inconsistent with the MOO-12 fix applied to get_user_flags::execute, unescaped rendering of fullname() / email user fields in a few HTML contexts (constrained but not eliminated by PARAM_NOTAGS), and use of deprecated Bootstrap 4 badge class names that Moodle 5.0 still honours through its compatibility shim. No high or critical issues are present.
Verdict. A well-engineered Moodle local plugin with a strong security posture. All entry points enforce login and capabilities, DB access goes through parameterised $DB calls, the privacy and backup APIs are implemented properly, and a recent third-party security review (changelog MOO-12) appears to have been remediated thoroughly, including allowlist validation on the previously XSS-prone verdict and signaltype fields plus defence-in-depth escaping at render time.
Residual findings. Three low-severity items remain:
-
NULL-context flag leak in
coursereport.php. The flagged-students summary query still uses(f.contextid IN (course tree) OR f.contextid IS NULL). The equivalent AJAX path inget_user_flags::executewas fixed under MOO-12 to suppress NULL-context records for non-system callers, but the server-rendered course summary missed that change. The practical impact is limited because NULL-context flags only arise when a beacon/external call is made withcontextid=0, which the plugin's normal quiz-page flow does not produce. -
Unescaped
fullname()/ email rendering. A handful of headings, link bodies, and<option>labels renderfullname($user)and$user->emaildirectly insidehtml_writer::tag()/html_writer::link()contents, which do not auto-escape body text. Moodle'sPARAM_NOTAGSfilter on first/last name and the constrained email format make practical exploitation difficult, butformat_string()should still be applied at output. -
Deprecated Bootstrap 4 badge class names.
badge badge-danger,badge badge-warning,badge badge-info, etc. are used throughout. Moodle 5.0 still renders them viabs4-compat.scss, but they're marked deprecated and outline themselves in red underthemedesignermode/ behat.
Strengths. Comprehensive privacy provider (including get_users_in_context, delete_data_for_users, conditional system-context inclusion based on actual NULL-context data); backup deliberately excludes useragent and ipaddress; capability definitions carry riskbitmask; the JS bundle is built with a documented build.js rather than checked-in opaque minified blobs; the signal_manager::update_user_flag write path wraps the read-modify-write in a delegated transaction and triggers events outside the critical section; the per-context cache in local_agentdetect_format_context_link() is a real N+1 prevention.
Findings
The flagged-students summary query in local_agentdetect_display_flagged_students() uses a WHERE (f.contextid IN (course context tree) OR f.contextid IS NULL) clause, so any flag row whose contextid is NULL appears in the per-course summary table for every course where the target user is enrolled.
This is the same pattern that was identified and fixed under MOO-12 finding #2 for the AJAX path. \local_agentdetect\external\get_user_flags::execute() now restricts NULL-context inclusion to system-context callers:
$contextwhere = $includesystem ? "(f.contextid {$ctxinsql} OR f.contextid IS NULL)" : "f.contextid {$ctxinsql}";
The equivalent server-rendered query in coursereport.php never had the same conditional applied, so the visibility boundary is enforced consistently in the AJAX flag-lookup path but leaks in the page-rendered summary.
Information exposed: aggregate flag metadata for enrolled users — flagtype, maxscore, detectioncount, last-detected timestamp, plus the user's name/email (which the teacher already sees as the course participant). The detail view (local_agentdetect_display_student_signals) and its underlying signal queries do correctly restrict on the course context tree, so detailed signaldata is not exposed.
Low risk. The exposure is constrained on three axes:
- Audience. The caller must already hold
local/agentdetect:viewreportson the course and the affected user must already be enrolled in that course, so name/email is not new information. - Data leaked. Only the aggregate flag row (
flagtype,maxscore,detectioncount, last-detected timestamp) is exposed — not the underlyingsignaldata,useragent, oripaddress, all of which require the higherviewsignalscapability at the system context. - Likelihood of NULL-context rows existing. The Lite client only activates on
mod-quiz-*pagetypes viahook_callbacks::load_detector()and always passes the page context to the external function and beacon. NULL-context rows only arise from external/beacon callers that passcontextid = 0or an unresolvable contextid — uncommon in normal operation.
The finding is reported as a low-severity consistency gap with the MOO-12 fix rather than an exploitable vulnerability; the right action is to close the inconsistency and add a regression test mirroring the existing get_user_flags NULL-context test.
coursereport.php exposes two views, both gated by require_capability('local/agentdetect:viewreports', $coursecontext) at the top of the page: a course-level summary of flagged enrolled users (local_agentdetect_display_flagged_students) and a per-student detail view (local_agentdetect_display_student_signals). The MOO-12 remediation tightened the visibility boundary in the per-student detail path (via local_agentdetect_user_visible_in_course()) and in the AJAX flag lookup (get_user_flags::execute). The summary view's enrolled-users filter (if (!in_array((int) $flag->userid, $enrolledids))) blocks the IDOR portion of MOO-12 #2, but the underlying SQL's OR f.contextid IS NULL predicate remained in place, so flags whose original context was outside any course (or whose context was lost) still surface to course-scoped callers.
-
As a manager, store a beacon-style signal with
contextid = 0(NULL on insert) for a studentSwho is enrolled in courseC, e.g. by callingsignal_manager::store_signal($S->id, 0, 'sess-null', 'combined', ['combinedscore' => 85, 'verdict' => 'HIGH_CONFIDENCE_AGENT']). This creates a NULL-context row inlocal_agentdetect_flags. -
Log in as a teacher with
local/agentdetect:viewreportsonCand navigate to/local/agentdetect/coursereport.php?courseid=<C.id>. -
The flagged-students table includes a row for
Swith the NULL-context flag'sflagtype,maxscore,detectioncount, and last-detected timestamp — data that originated outside any course context and that the MOO-12 fix would have suppressed for the AJAX path.
$sql = "SELECT f.userid, u.firstname, u.lastname, u.firstnamephonetic, u.lastnamephonetic,
u.middlename, u.alternatename, u.email,
MAX(f.maxscore) AS maxscore,
MAX(f.detectioncount) AS detectioncount,
MAX(f.timemodified) AS lastdetected,
f.flagtype
FROM {local_agentdetect_flags} f
JOIN {user} u ON u.id = f.userid
WHERE (f.contextid {$ctxinsql} OR f.contextid IS NULL)
AND f.flagtype != 'cleared'
GROUP BY f.userid, u.firstname, u.lastname, u.firstnamephonetic, u.lastnamephonetic,
u.middlename, u.alternatename, u.email, f.flagtype
ORDER BY maxscore DESC, lastdetected DESC";
Drop the OR f.contextid IS NULL branch from the WHERE clause so the summary view honours the same context-tree boundary as the detail view and get_user_flags::execute():
$sql = "SELECT f.userid, u.firstname, u.lastname, u.firstnamephonetic, u.lastnamephonetic,
u.middlename, u.alternatename, u.email,
MAX(f.maxscore) AS maxscore,
MAX(f.detectioncount) AS detectioncount,
MAX(f.timemodified) AS lastdetected,
f.flagtype
FROM {local_agentdetect_flags} f
JOIN {user} u ON u.id = f.userid
WHERE f.contextid {$ctxinsql}
AND f.flagtype != 'cleared'
GROUP BY f.userid, u.firstname, u.lastname, u.firstnamephonetic, u.lastnamephonetic,
u.middlename, u.alternatename, u.email, f.flagtype
ORDER BY maxscore DESC, lastdetected DESC";
If a teacher needs visibility of NULL-context flag rows for an enrolled student, add a feature-flag setting and route it through the same \context_system gating used by get_user_flags. Add a regression test mirroring test_null_context_records_hidden_from_course_callers() for the page-rendered summary.
fullname() and $user->email are interpolated directly into html_writer::tag() and html_writer::link() body content in several places. Those wrappers escape attribute values via s() but emit body content verbatim, so any HTML metacharacter that survives Moodle's input filtering would render as raw markup.
Moodle's PARAM_NOTAGS filter (applied to firstname/lastname at user form intake via strip_tags()) removes well-formed tag patterns, which substantially limits practical XSS. It does not entity-escape isolated characters like &, <, ", or ', however, and admin-level updates can sometimes bypass the form filter entirely. The safer pattern is to escape user-supplied strings at output, either with format_string() (which adds Moodle's filter pipeline) or s() (raw HTML-escape), matching how the plugin already handles verdict and signal name fields.
Affected sites:
report.phpline 207 —fullname($u)inside aget_stringsubstitution rendered as<option>label text byhtml_writer::select.report.phpline 284 —fullname($selecteduser)inside aget_stringsubstitution rendered as<h3>body.report.phpline 368 —fullname($signal)rendered as<a>body content.report.phpline 553 —fullname($flag) . ' (' . $flag->email . ')'rendered as<a>body content.coursereport.phpline 135 —fullname($flag)rendered as<a>body content.coursereport.phpline 200 —fullname($user)inside aget_stringsubstitution rendered as<h3>body.
Low risk. Practical exploitation is constrained by:
- Moodle's
PARAM_NOTAGSfilter on the user form, which strips well-formed HTML tags from first/last name on entry. - The display sites are gated by
local/agentdetect:viewreports(teacher+) orlocal/agentdetect:viewsignals(manager+), so the attacker needs an attacker-controlled username and a privileged viewer. - Email rendering at line 553 is limited by Moodle's email format validation, which restricts the character set further.
The risk is real but small: an admin who bypasses front-end filtering (or a user account migrated from a less-strict source) could land HTML metacharacters in storage, and a manager/teacher visiting the report would render them unescaped. The fix is mechanical (s(fullname(...))) and matches the escaping discipline the plugin already applies elsewhere.
Throughout the plugin, attribute values pass through html_writer::attribute() which automatically applies s(). Body content does not. The plugin already follows the correct pattern for the verdict enum (escaped via s() in the default render branch as defence in depth) and for signal name fields (s($a->name ?? ''), s($cs->name) in report.php), so the fix here is to apply the same discipline to the few remaining fullname() and $user->email sites.
A direct exploit requires a Moodle user whose first or last name survives PARAM_NOTAGS filtering with HTML metacharacters intact. strip_tags() removes well-formed tag patterns, but a payload like Joe & <Mary> becomes Joe & (well-formed <Mary> stripped, ampersand kept) and would render as Joe & in the heading — a harmless rendering quirk rather than script execution.
For a script-execution PoC, an admin would need to bypass PARAM_NOTAGS and set a name like "><img src=x onerror=alert(1)> directly via the admin user editor (which has historically permitted broader input than the front-end profile form). Once stored, viewing the course-report detail page or admin signals page would execute the payload because the heading and link bodies emit the name without s() or format_string().
$scorelabel = $u->maxscore >= 70 ? ' ⚠️' : ($u->maxscore >= 40 ? ' ⚡' : '');
$optionparams = (object) ['fullname' => fullname($u), 'signalcount' => $u->signalcount, 'maxscore' => $u->maxscore];
$useroptions[$u->id] = get_string('report:useroption', 'local_agentdetect', $optionparams) . $scorelabel;
Escape the substituted name before interpolation:
$optionparams = (object) [
'fullname' => s(fullname($u)),
'signalcount' => (int) $u->signalcount,
'maxscore' => (int) $u->maxscore,
];
echo html_writer::tag('h3', get_string('report:signalsfor', 'local_agentdetect', fullname($selecteduser)));
echo html_writer::tag('h3', get_string('report:signalsfor', 'local_agentdetect', s(fullname($selecteduser))));
$userlink = html_writer::link(
new moodle_url('/local/agentdetect/report.php', ['tab' => 'signals', 'userid' => $signal->userid]),
fullname($signal),
['title' => $signal->email]
);
$userlink = html_writer::link(
new moodle_url('/local/agentdetect/report.php', ['tab' => 'signals', 'userid' => $signal->userid]),
s(fullname($signal)),
['title' => $signal->email]
);
$userlink = html_writer::link(
new moodle_url('/local/agentdetect/report.php', ['tab' => 'signals', 'userid' => $flag->userid]),
fullname($flag) . ' (' . $flag->email . ')'
);
$userlink = html_writer::link(
new moodle_url('/local/agentdetect/report.php', ['tab' => 'signals', 'userid' => $flag->userid]),
s(fullname($flag) . ' (' . $flag->email . ')')
);
$userlink = html_writer::link(
new moodle_url('/user/view.php', ['id' => $flag->userid, 'course' => $courseid]),
fullname($flag)
);
$userlink = html_writer::link(
new moodle_url('/user/view.php', ['id' => $flag->userid, 'course' => $courseid]),
s(fullname($flag))
);
echo $OUTPUT->heading(get_string('coursereport:studentsignals', 'local_agentdetect', fullname($user)), 3);
echo $OUTPUT->heading(get_string('coursereport:studentsignals', 'local_agentdetect', s(fullname($user))), 3);
The plugin emits Bootstrap 4 contextual badge classes (badge-danger, badge-warning, badge-info, badge-success, badge-secondary, badge-dark) in report.php, coursereport.php, and lib.php. Moodle 5.0 ships Bootstrap 5, where the equivalent pattern is badge bg-<colour> (e.g. badge bg-danger). The Boost theme's bs4-compat.scss still renders the old names through a deprecated-styles mixin, so functionally the badges still display, but:
- The compatibility mixin marks these rules with a red outline + warning pseudo-element under
body.behat-siteandbody.themedesignermode, so plugin CI runs and theme designers will see the deprecation. - The compatibility layer is intended to be removed in a future release, at which point the badges would lose their colour styling.
The fix is a mechanical rename across the same files where Moodle core has already migrated its own badge usage.
Low risk. Purely a code-quality / forward-compatibility item — there is no security impact. The badges render correctly in current Moodle 5.0 production environments; they would lose their background colour if and when Moodle drops the bs4-compat layer. Fixing now is a mechanical search-and-replace that aligns the plugin with how Moodle core already renders contextual badges.
Moodle's Boost theme bundles a Bootstrap-4-compat shim at theme/boost/scss/moodle/bs4-compat.scss that maps .badge-danger, .badge-warning, etc. to the new bg-* utilities via the deprecated-styles mixin defined in theme/boost/scss/moodle/deprecated.scss. The mixin makes the rule visually flag itself under themedesignermode and behat-site, signalling that the class is deprecated and may be removed in a future Moodle release.
return html_writer::tag('span', $label, ['class' => 'badge badge-danger']);
} else if ($flagtype === \local_agentdetect\signal_manager::FLAG_LOW_SUSPICION) {
return html_writer::tag('span', $label, ['class' => 'badge badge-warning']);
}
return html_writer::tag('span', $label, ['class' => 'badge badge-secondary']);
return html_writer::tag('span', $label, ['class' => 'badge bg-danger']);
} else if ($flagtype === \local_agentdetect\signal_manager::FLAG_LOW_SUSPICION) {
return html_writer::tag('span', $label, ['class' => 'badge bg-warning']);
}
return html_writer::tag('span', $label, ['class' => 'badge bg-secondary']);
if ($combined >= 70) {
$combined = html_writer::tag('span', $combined, ['class' => 'badge badge-danger']);
} else if ($combined >= 40) {
$combined = html_writer::tag('span', $combined, ['class' => 'badge badge-warning']);
} else {
$combined = html_writer::tag('span', $combined, ['class' => 'badge badge-success']);
}
Rename to the Bootstrap 5 utility prefix — badge bg-danger, badge bg-warning, badge bg-success. The same replacement applies to all verdict badges later in the file (badge badge-warning, badge badge-info, badge badge-success, badge badge-secondary, badge badge-dark).
if ($score >= 70) {
return html_writer::tag('span', $score, ['class' => 'badge badge-danger']);
} else if ($score >= 40) {
return html_writer::tag('span', $score, ['class' => 'badge badge-warning']);
}
return html_writer::tag('span', $score, ['class' => 'badge badge-success']);
Switch to the Bootstrap 5 utility prefix:
if ($score >= 70) {
return html_writer::tag('span', $score, ['class' => 'badge bg-danger']);
} else if ($score >= 40) {
return html_writer::tag('span', $score, ['class' => 'badge bg-warning']);
}
return html_writer::tag('span', $score, ['class' => 'badge bg-success']);
Apply the same replacement in local_agentdetect_format_verdict_badge() (lines 535–572) and any other badge badge-* occurrence in this file.
Strong security baseline. Every page entry calls require_login() plus a context-appropriate require_capability(). The beacon endpoint additionally validates the submitted contextid against a fresh require_login($course, false, $cm, false, true) so a beacon cannot tag signals with a context the user has no access to. The external functions go through validate_parameters() and validate_context(). All DB writes go through $DB with named placeholders; no raw SQL string interpolation of user input was observed.
MOO-12 remediation history is visible and well-tested. The changelog and tests/ directory document a thorough third-party security review response: verdict and signaltype are now allowlist-validated in signal_manager::store_signal() (closing the stored-XSS surface), the AJAX get_user_flags filters callers' user-ID lists to enrolled-and-group-visible participants and suppresses NULL-context records for non-system callers, and the course-report detail view requires the target user to satisfy is_enrolled + groups_user_groups_visible. Each fix has an accompanying regression test (test_store_signal_rejects_invalid_verdict, test_filters_out_non_enrolled_userids, test_null_context_records_hidden_from_course_callers, test_separate_groups_block_cross_group_access).
Privacy provider is comprehensive. All three privacy interfaces are implemented (metadata\provider, core_userlist_provider, request\plugin\provider). get_contexts_for_userid() only adds the system context when the user actually has NULL-context rows, avoiding the spurious "data exists at system context" report in privacy tooling. delete_data_for_users() correctly scopes by both the supplied userlist and the context. Sensitive identifiers (useragent, ipaddress) are also documented in the metadata.
Backup deliberately drops PII. backup_local_agentdetect_plugin excludes useragent and ipaddress from the per-signal backup payload (documented inline), so course backups do not carry personal identifiers across instances. Live per-user export/deletion of those fields is still covered by the Privacy API.
Race-aware flag updates. signal_manager::update_user_flag() wraps the read-modify-write of local_agentdetect_flags in a delegated transaction and triggers the user_flagged event outside the transaction, so event listeners do not run inside the critical section. The comment block explicitly acknowledges that the (userid, contextid) unique index cannot constrain duplicate NULL-context rows on MySQL/MariaDB/PostgreSQL and explains the chosen trade-off — clear reasoning for future maintainers.
N+1 prevention. local_agentdetect_format_context_link() caches resolved context HTML per contextid for the request lifetime, avoiding the obvious context::instance_by_id + get_coursemodule_from_id + get_course cascade per row when rendering hundred-row tables. local_agentdetect_display_student_signals() similarly bulk-loads the highest-scoring combined record per session into one query instead of looping.
No third-party PHP libraries bundled. thirdpartylibs.xml is not present and is not required: the plugin's vendor-free build.js only pulls Babel + Terser as devDependencies, those tools run at build time on the developer machine, and their output is the same JS the plugin authored — not bundled third-party runtime code. The amd/build/*.min.js files are minified outputs of amd/src/*.js. The README explicitly documents the build command (npm install && node build.js) and notes that CI also rebuilds via moodle-plugin-ci grunt.
No tests for report.php, beacon.php, hook callbacks, or backup/restore. Existing tests cover the security-critical class boundaries that the MOO-12 audit exercised, but the page entry scripts and the beacon endpoint have no integration coverage. Adding a Behat scenario for the admin report (including the JSON download path) and a unit test that exercises beacon.php's context-access check would tighten the test net without requiring large refactors.
Event get_description() strings are hardcoded English. signal_detected::get_description() and user_flagged::get_description() build their text inline rather than via get_string(). This matches the prevailing pattern in Moodle core's event classes, so it is not a finding, just an observation: if you decide to translate event log output later, these would need to move to language strings.