Commit graph

58 commits

Author SHA1 Message Date
Clupai8o0
d32b8c885a COL-BOT-01: fix /unlink auth, deferral and delete-failure audit (review)
Address adversarial-review findings on /unlink:

- Self-service-in-DM / cross-guild destructive (CRITICAL): the MANAGE_ROLES gate is not
  trusted for auth -- poise 0.6 treats a DM invoker as Permissions::all(), so it passed
  in a DM and let /unlink @self delete the row before any guild check. The command is now
  guild_only and, as the FIRST thing before any DB op, asserts ctx.guild_id() is the DSEC
  guild specifically (which also stops a moderator of another guild the bot is in from
  deleting DSEC rows). The gate is kept for command visibility only.

- Expired-interaction mutate-then-skip-audit (MED): defer_ephemeral is sent before any
  DB/HTTP work; the moderator reply is best-effort so a failed reply cannot skip the audit
  log, which now always runs.

- Delete failure skipped audit + could leak (MED): the row delete no longer uses `?`. A
  delete error is handled in-command with a sanitized ephemeral (no raw error) and a
  distinct failure audit line; the raw error is redacted (redact_digits) before it reaches
  stderr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
2026-08-30 17:43:06 +10:00
Clupai8o0
2c8c1818ec SEC-19: fix 7 review defects in the verification hardening
Address adversarial-review findings on the verify path:

- Already-linked caller bypass (HIGH): add_dsec_discord_table no longer early-returns
  Linked on any existing row. It now checks the SUBMITTED id's owner first (idempotent
  only when that owner is the caller) and refuses a caller already linked to a DIFFERENT
  student id -- closing the path where a stale COR-03 partial-insert row let an account
  claim someone else's id.

- Check-then-insert race (HIGH): the insert now catches a UNIQUE(student_id) violation
  (SQLSTATE 23505), re-queries the owner and converts it to the generic refusal + audit.
  SECURITY.md now marks the live UNIQUE constraint (after the dup sweep) as a merge/deploy
  gate, not optional.

- PII leak in error prints (HIGH): raw supabase/reqwest errors (which embed
  student_id=eq.<id> and a 23505 Key detail) are never printed on the verify path. New
  redact_digits() masks 7+ digit runs; the interaction id is used as an opaque
  correlation ref. student_id_owner now selects only discord_id.

- Concurrent-attempt rate-limit bypass (HIGH): a per-UserId tokio Mutex (AppState.
  verify_locks) serializes a whole verification attempt so concurrent modal submits
  cannot each slip under the 5-in-15min limit. Requires tokio "sync" feature.

- Ownership conflict never counted (MED): a refused link now records exactly one failed
  attempt (success/infra-error record none), so a stolen-but-claimed credential can no
  longer loop the query set forever.

- Expired-interaction mutate-then-skip (MED): defer_ephemeral is now sent the instant the
  modal arrives, before any DB/logs work; every later reply edits the deferred response.

- Log oracle (MED): the failed-attempt audit line is one fixed generic string, so
  "not found" vs "name mismatch" are indistinguishable in the logs channel too.

Also: on_error no longer forwards a handled FrameworkError::Command to poise's builtin
(whose Command arm does a non-ephemeral ctx.say(raw_error), leaking DB error text); mutex
locks recover from poisoning instead of panicking; and the attempt/lock maps evict
stale/idle entries so they cannot grow unbounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
2026-08-30 17:42:54 +10:00
Clupai8o0
c41f478b1b COL-BOT-01: add /unlink to undo hijacked verifications + manual runbook
Add a moderator-gated /unlink <user> slash command (required_permissions =
"MANAGE_ROLES", same gate as embed()). It deletes the dsec_discord_members row FIRST,
then removes the verified role -- that order avoids leaving a member un-roled but still
linked, the state that permanently breaks /member_info. The delete uses .returning(...)
so PostgREST returns the row body (the COR-03 empty-204 gotcha) and so we can tell
whether a link actually existed. It replies ephemerally with what it did and reports
clearly if the role-removal half fails so a human can finish it, then writes a log_embed
entry to the logs channel naming the acting moderator. Registered in main.rs.

Add SECURITY.md: the moderator runbook for undoing a link (via /unlink and by hand),
who holds the Supabase credentials, the fact that deleting the row does NOT revoke the
Discord role (why /unlink does both), and the owner-only SEC-19 UNIQUE-constraint step.

A member-facing /unverify is deliberately not added. The /member_info leave/trim/remove
policy call is an owner decision and is intentionally not implemented here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
2026-08-30 17:11:08 +10:00
Clupai8o0
959596aff5 SEC-19: harden Discord verification (identical failures, attempt limits, id uniqueness)
Collapse both verification failure paths into one shared verification_failed_embed()
constructor so "not found" and "name mismatch" are byte-identical and can no longer be
used as an oracle for whether a student id holds an Active membership.

Remove the stale in-memory student_cache entirely: it was consulted before the only
query carrying membership_status = "Active", had no TTL and was never evicted, making it
a stale-membership bypass in a long-lived container. fetch_student now runs on every
verification attempt before any role grant.

Add a per-Discord-user failed-attempt counter in AppState
(Mutex<HashMap<UserId, (u32, Instant)>>). After 5 failures in 15 minutes an attempt is
refused with the generic embed and no database round trip. Each failure is logged to the
logs channel with the Discord user id and a timestamp only -- never the submitted name
or student id.

Add an application-level student-id uniqueness check in add_dsec_discord_table: if a
different discord_id already holds the student id, refuse with the generic embed and log
the conflict to the logs channel with both discord ids (not the student id).

The UNIQUE constraint DDL and duplicate sweep on live Supabase are an owner step (see
SECURITY.md); the emailed-OTP possession proof is noted as a follow-up TODO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE
2026-08-30 17:10:53 +10:00
Samridh Limbu
5918ceb1c8
Phase 1: bot fixes (BOT-02/03/04/05, COR-03, UXA11Y-11, OPS-04) + Codex hardening (#7)
* COL-BOT-03: make /member_info expiry reply ephemeral and drop dead query

Restructure the membership lookup to `let Some(user_data) = ... else { ... }`
so the "Couldn't find info" branch now sends `.ephemeral(true)` like the other
two replies; whether a member's membership has lapsed is no longer announced to
the whole channel. Delete the commented-out `student_data` query that duplicated
member_data(). No embed fields or wording changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* COL-BOT-05: parse all config once in AppState, remove per-event env reads

Finishes the AppState config hoisting (COR-12's groundwork was not present in
the repo, so this ticket establishes it): AppState now carries guild_id,
honeypot_channel_id, leetcode_channel_id, verified_role_id, logs_channel_id and
weather_token, all parsed once in AppState::new(). A missing or unparseable id
now produces one boot error naming every offending variable plus a pointer to
.env.example, instead of a per-event .expect() panic (a data race under the
multi-threaded runtime, since dotenv() calls the now-unsafe set_var).

- main.rs: new fields, required_u64() collector, fail-fast with combined message.
- events/message.rs: honeypot/create_leetcode_thread/on_message take &Data and
  read channel/guild ids from AppState; dropped dotenv() and env::var.
- events/interaction_create.rs: deleted verified_role_id(); use
  data.state.verified_role_id.
- commands/mods_only.rs: log_embed takes logs_channel_id: ChannelId; caller in
  message.rs passes data.state.logs_channel_id.
- commands/weather.rs: WEATHER_TOKEN read once into AppState (non-fatal), passed
  to get_weather — required so the new CI grep guard can be clean.
- .env.example: add LEETCODE_CHANNEL_ID.
- .github/workflows/ci.yml: add "No environment reads outside main.rs" guard.

grep -rn 'dotenv()\|env::var' src/ | grep -v '^src/main.rs:' now returns nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* COL-BOT-02: guard the honeypot, log failed bans, stop log_embed panicking

The honeypot banned whoever posted, unconditionally. Add guards so it never
bans a bot, a webhook, the bot itself, or anyone who can moderate
(ban_members / manage_messages / administrator, computed from the cached guild).
A failed ban now logs the user and error instead of being silent.

Remove the .expect("LOG FAIL") in log_embed: it runs inside a message handler,
so a transient Discord failure was a panic; it now eprintln!s and returns.

Decouple on_message so honeypot and the LeetCode thread each run and log
independently — a failure in one no longer skips the other, and neither
propagates out of the event handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* COL-BOT-04: clamp LeetCode thread names and add the repo's first tests

create_title_from_message dropped the two-character numbering prefix but a
first line of two chars or fewer left an empty name, and a line over 100 chars
exceeded Discord's limit — both produced a silent 400. Move the function to
module level (&str), clamp to 100 characters (on a char boundary, not bytes),
and fall back to "LeetCode discussion" when nothing is left. A failed
create_thread now logs instead of propagating. Add six unit tests covering
normal input, empty, a two-char line, a >100-char line, a multibyte first
character, and a multi-line message — the first tests in this repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* COR-03: fix verification failing on a member's first attempt

The insert into dsec_discord_members deserialised the response into
Vec<DiscordMemberRow>, but without `.returning(...)` supabase-lib-rs never sends
`Prefer: return=representation`, so PostgREST returns a 201 with an empty body.
Parsing that empty body failed and the `?` aborted before add_role and before
any reply — so every member's first click died with "This interaction failed",
while the row was still written (which is why the second attempt worked). Add
`.returning("student_id,discord_id")` so the row comes back and parses.

Also wrap the post-modal verification work so any failure sends the user an
ephemeral "something went wrong" instead of a dead interaction (poise's on_error
has no handle to the modal submission), and set an explicit on_error on
FrameworkOptions that replies ephemerally on command errors and keeps the
default logging. The database-write-first ordering is left unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* NEW-UXA11Y-11: normalise names and student IDs before comparing them

Verification compared names with exact lowercased equality and never trimmed, so
a trailing space, a double space, or a missing middle name turned a real paid
member away — and a pasted "s123456789 " was looked up with the trailing space
and missed. Add normalise_name (trim + collapse whitespace), normalise_student_id
(strip whitespace and a leading "s"), and name_matches (equal, or every typed
word present in the roster name in order), and route the database path, the cache
path (cached_name_matches / cache_student) and the student-id lookup through them.
"Doe John" still does not match "John Doe" and an empty name matches nothing.
Update the modal placeholder to "As it appears on your DUSA membership". Four unit
tests added. The two failure embeds are left byte-identical (SEC-19 owns those).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* OPS-04: restart on crash, real logging, and a deploy that fails when the bot does

- docker-compose.yml: `restart: unless-stopped` so a panic, bad frame or VPS
  reboot brings the bot back instead of leaving it dead. Comment explaining why
  no healthcheck (slim runtime, no HTTP port).
- Cargo.toml: enable tracing-subscriber's env-filter feature (declared but never
  initialised until now).
- main.rs: initialise tracing as the first statement in main(), defaulting to
  `info` — serenity/poise/supabase logs now surface, without leaking student IDs
  or the service email that the Supabase client emits at `debug`.
- deploy.yml: after `up -d`, assert the container is actually Running 30s later
  and dump its logs and fail if not — `up -d` returns 0 on container creation,
  not on a working process.
- Dockerfile: pin the builder to rust:1-bookworm to match the bookworm-slim
  runtime, removing the trixie/bookworm glibc mismatch.
- README: document the info-level default and the RUST_LOG=debug PII footgun.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* NEW-UXA11Y-11: require first AND last name to match (close a verification bypass)

Codex review: name_matches accepted any non-empty ordered subset, so a single
common token ("John" or "Doe") matched "John Michael Doe" — a student id plus one
name token could claim the verified role for someone else. Now both the first and
last tokens must match, middle tokens the student typed must appear in the roster
in order (omitted middles still fine), and a single token, an arbitrary subset,
or a wrong first/last name is rejected. Add tests: single-token input, wrong
surname and wrong first name do NOT verify; full name and first+last with the
middle omitted do.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* OPS-04: make the deploy assertion catch a crash-looping bot

Codex review: with `restart: unless-stopped` a bot that panics at boot is
restarted, so a single `docker inspect` still reads Running while it crash-loops
— the deploy went green while broken. The assertion now waits for the bot to
reach READY (it logs "Logged in as ..." from the Ready handler) within a
stability window AND requires RestartCount == 0, breaking early and failing the
moment a restart is observed, dumping container logs on any failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* COL-BOT-05: stop /weather panicking on missing token or error payloads

Codex review: a missing/blank WEATHER_TOKEN and invalid locations produced an API
error body that then hit as_str().unwrap() calls. Store the token as
Option<String> (None when missing or blank) and disable /weather with a friendly
ephemeral reply instead of running with an empty key. get_weather now returns the
HTTP status with the parsed body; a non-2xx surfaces the API's error message, and
every success field is read via JSON pointers with no unwrap() on external JSON.
The thumbnail is only set when an icon URL is actually present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

* COL-BOT-02: make the honeypot moderator guard fail safe

Codex review: the guard fell THROUGH to banning when the member lookup or the
permission calculation failed, and it ignored per-channel permission overwrites.
Now it computes effective permissions in the honeypot channel (honouring
overwrites via Guild::user_permissions_in) and ABSTAINS from banning whenever the
guild, the member, or the channel cannot be resolved — fail-safe: never ban when
identity or permissions cannot be established. Replaces the deprecated
Member::permissions call, so the #[allow(deprecated)] is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XrE7F9ZuBWdQnS8CZvYDE

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-30 16:01:55 +10:00
Samridh Limbu
345f4ec513
SEC-01: pull-request CI on a hosted runner, and stop the deploy leaving DOT_ENV on the VPS (#5)
* SEC-01: add pull-request CI and stop the deploy leaving DOT_ENV on the VPS

A merge to main runs the merged commit as root on the club VPS through the
self-hosted runner, and this repo has no PR CI, no required review and no
status check.

- .github/workflows/ci.yml: fmt / clippy / build --locked / test on
  ubuntu-latest, contents: read, both actions pinned by commit SHA. Never
  the VPS.
- deploy.yml: keep push to main, add workflow_dispatch, permissions
  contents: read, environment: production, and an if: always() step that
  removes the .env the deploy writes into the workspace.
- .dockerignore: keep that .env, target/ and .git out of the build context.
- Dockerfile: --locked on both cargo build --release lines.

environment: production gates nothing until required reviewers are configured
on the environment itself, and the ruleset still requires zero approving
reviews. Both are owner-only and listed in the pull request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* SEC-01: make cargo fmt and clippy clean so the CI job can go green

The CI workflow added in #5 fails on code that predates it: three files were
unformatted and `cargo clippy --all-targets -- -D warnings` reported 26 errors.
A red check cannot be made a required status check on the protect-main ruleset,
which is what this unblocks.

`cargo fmt --all` over three files, and 26 clippy errors resolved: 18 via
`cargo clippy --all-targets --fix`, the rest by hand. Two fixes uncovered lints
that had been masked (an `unnecessary_unwrap` in info.rs behind the needless
borrow on the line above, and two `unnecessary_to_owned` at the call sites of a
signature that moved from `&String` to `&str`), so 28 fixes for 26 warnings.

Three `#[allow]`s where the only real fix would change a signature or a public
API: `result_large_err` on `AppState::new` (the large variant is
`supabase::Error`, owned by supabase-lib-rs) and `too_many_arguments` on
`log_embed` and on the `embed` slash command.

No behaviour change. src/ only; .github/, Dockerfile, .dockerignore, Cargo.toml
and Cargo.lock are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* SEC-01: pin actions/checkout in the deploy workflow to a commit SHA

deploy.yml runs on the self-hosted VPS runner with passwordless sudo, so a
mutable tag on this action is a code-execution path onto that host if the tag
is ever moved. Pinned to the commit v4 currently resolves to
(11d5960a326750d5838078e36cf38b85af677262), verified against upstream — this is
the same code the deploy already runs today, not a version bump. ci.yml is on
v7.0.1; the deploy path is deliberately left on v4 so that pinning does not
smuggle a major-version change into a workflow whose only test is a live deploy.

Raised by Codex review of PR #5 as the one unpinned `uses:` in either workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:08:52 +10:00
RythonDev
88e50b1dc4
ensure member is active before receiving member role (#4) 2026-08-17 11:24:13 +10:00
RythonDev
b83ba5428d
Merge pull request #3 from dsec-hub/new_thread_title
updated gitignore, updated thread title to be leetcode titles
2026-08-10 00:44:47 +10:00
liyunze
36adb16a74 updated gitignore, updated thread title to be leetcode titles 2026-08-10 00:32:15 +10:00
RythonDev
98c49668c1
Merge pull request #2 from dsec-hub/leetcode-thread-creation
added leetcode thread creation
2026-08-09 21:50:26 +10:00
liyunze
a0bfd5531c added leetcode thread creation 2026-08-09 21:43:53 +10:00
liyunze
a9fff4be75 fallback for supabase failed connection 2026-08-08 13:09:59 +10:00
liyunze
2e3da35f91 updated github actions 2026-08-05 17:28:07 +10:00
RythonDev
941c02d2f2
Merge pull request #1 from dsec-hub/row-level-security
added row level security
2026-07-09 16:54:40 +10:00
liyunze
75d54742c3 added row level security 2026-07-09 16:51:50 +10:00
liyunze
194ae28503 made reponse ephemeral 2026-07-09 11:33:40 +10:00
liyunze
4595ee4cf7 member info command 2026-07-09 01:33:51 +10:00
liyunze
9f88bf05b8 organised functions 2026-07-07 19:06:17 +10:00
liyunze
c818ddd8e5 update interaction create 2026-07-06 21:53:48 +10:00
liyunze
fb4c133ba6 trigger GitHub actions 2026-07-06 21:53:21 +10:00
liyunze
87f1fb4101 fixed github actions 2026-07-03 22:05:07 +10:00
liyunze
c9def526c8 trigger GitHub actions 2026-07-03 20:45:56 +10:00
liyunze
208796e7e3 trigger GitHub actions 2026-06-18 14:16:14 +10:00
liyunze
6bf8e0c99d added logging honeypot 2026-06-18 13:24:24 +10:00
liyunze
746d019a05 added honeypot and send log 2026-06-18 00:08:04 +10:00
liyunze
4fb7280680 trigger GitHub actions 2026-06-17 20:52:12 +10:00
liyunze
f7b9f73bec removed Arc 2025-11-29 18:12:23 +08:00
liyunze
e4b1904c93 fixed verification embed command 2025-11-28 16:24:09 +08:00
liyunze
27e84e98a7 updated verify command 2025-11-28 16:09:53 +08:00
liyunze
163be0c990 fix variables, added events 2025-11-27 19:55:02 +08:00
liyunze
54ba36ee1f added embed command 2025-11-24 11:03:37 +11:00
liyunze
7e6d4f014c added env vars 2025-11-23 22:47:05 +11:00
liyunze
72937ed5b9 2 subcommands 2025-11-23 21:06:15 +11:00
liyunze
7ff93e9d25 Merge branch 'verify-command' of https://github.com/deakinSoftwareEngineeingClub/DSEC-Discord-Bot into verify-command 2025-11-23 19:24:30 +11:00
liyunze
2687731bfa added verification command 2025-11-23 19:24:05 +11:00
liyunze
caa2cfb162 added verification command 2025-11-23 19:20:11 +11:00
liyunze
b9d0640707 updated license 2025-11-22 10:04:16 +11:00
liyunze
adb623d471 updated deploy yml 2025-11-16 16:50:03 +11:00
liyunze
c76f1233ac updated serverinfo and info files 2025-11-12 13:02:27 +11:00
liyunze
69474aafe6 updated CI/CD pipeline and added bot info command 2025-11-12 00:14:04 +11:00
liyunze
a20d68855f updated deploy yml 2025-11-11 23:44:31 +11:00
liyunze
8b56887761 updated help command 2025-11-11 23:41:40 +11:00
liyunze
5b401ac2e0 added sudo for permission 2025-11-11 22:38:41 +11:00
liyunze
a966f62b81 fixed path 2025-11-11 22:37:59 +11:00
liyunze
09b0722f08 added deploy yml for Github Actions CI/CD 2025-11-11 22:37:08 +11:00
liyunze
a2b6baae97 updated dockerfile 2025-11-10 22:19:28 +11:00
liyunze
f78292d6cd added expect messages for docker 2025-11-10 17:56:02 +11:00
liyunze
c16b5a01cb added weather command 2025-11-10 15:18:14 +11:00
liyunze
71f9337794 dockerized successfully 2025-11-10 15:12:21 +11:00
liyunze
cdffcd1b62 added weather command 2025-11-10 10:05:50 +11:00