Skip to content
Open
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
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
command: |
npm install
npm rebuild
npm run lint
npm test
- save_cache:
key: v1-dep-{{ .Branch }}-{{ checksum "package-lock.json" }}
Expand Down
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Slackbot environment configuration
# Replace the values below with real tokens from your Slack app configuration.
HUBOT_SLACK_APP_TOKEN=xapp-1-your-app-token
HUBOT_SLACK_BOT_TOKEN=xoxb-your-bot-token
# Optional HTTP listener port
PORT=8080
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ deploy/slackbotrc
deploy/slackinrc
yarn.lock
TAGS
.history/
2 changes: 1 addition & 1 deletion Procfile
Original file line number Diff line number Diff line change
@@ -1 +1 @@
web: bin/hubot --adapter slack
web: bin/hubot
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ pull requests or forking our code to make your own community website!
Before you get ahead of yourself, though, please read our
[Contributing Guide](https://github.com/lansingcodes/slackbot/blob/main/.github/CONTRIBUTING.md).

## Running the bot locally

- Install Node.js 18 or newer.
- Copy `.env.example` to `.env` (create the file if it does not exist) and populate:
- `HUBOT_SLACK_APP_TOKEN` – Slack App-Level token with `connections:write`.
- `HUBOT_SLACK_BOT_TOKEN` – Bot token with the scopes listed below.
- (Optional) set `PORT` to expose Hubot's HTTP listener if needed.
- Start the bot with `npm install` followed by `npm start` or `bin/hubot`.

### Required Slack scopes and events

Grant the Slack app the following bot scopes to match production:

`app_mentions:read`, `channels:join`, `channels:history`, `channels:read`, `chat:write`, `im:write`, `im:history`, `im:read`, `users:read`, `groups:history`, `groups:write`, `groups:read`, `mpim:history`, `mpim:write`, `mpim:read`

Enable these Socket Mode events: `app_mention`, `message.channels`, `message.im`, `message.groups`, `message.mpim`.

## License

[Hippocratic 2.1](https://firstdonoharm.dev)
Expand Down
2 changes: 1 addition & 1 deletion bin/hubot
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ set -e
npm install
export PATH="node_modules/.bin:node_modules/hubot/node_modules/.bin:$PATH"

exec node_modules/.bin/hubot --name "slackbot" "$@"
exec node_modules/.bin/hubot --name "slackbot" -a @hubot-friends/hubot-slack "$@"
58 changes: 58 additions & 0 deletions docs/security-upgrade-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Security Upgrade Plan

This document outlines the remediation steps needed to resolve the remaining npm vulnerabilities after PRs #85, #95, and #96.

## 1. Replace Deprecated Slack Adapter

- Swap `hubot-slack` for `@hubot-friends/hubot-slack`.
- Upgrade Hubot core to `^13.1.4` and adjust startup commands (Procfile, README) to use `hubot -a @hubot-friends/hubot-slack`.
- Require new env vars `HUBOT_SLACK_APP_TOKEN` and `HUBOT_SLACK_BOT_TOKEN`; document Socket Mode scopes/events.
- Refactor Slack-specific initializers/tests to remove dependencies on the legacy RTM client (notably `mentioned-rooms-referencer` and `watch-for-disconnected`).

## 2. Remove Vulnerable Legacy Helpers

- Drop unused `google-url` and `expand-url` dependencies to eliminate their transitively vulnerable `request`/`form-data` chain.
- Update `lib/templates/welcome-email.js` and its specs to stub `shorten-url` instead of making live shortening calls.

## 3. Upgrade Firebase SDK

- Bump `firebase` to `^12.4.0` to pick up patched `@grpc/grpc-js`.
- Verify Firestore usage (`events-fetcher`) against new APIs and adjust if necessary.

## 4. Modernize Tooling

- Update `standard` to `^17` (and any transitive lint deps).
- Add Node engine constraint `>=18`.
- Update CircleCI workflow to run `npm run lint && npm test`.

## 5. Verification

- Blow away lockfile and reinstall: `rm -rf node_modules package-lock.json && npm install`.
- Run `npm run lint`, `npm test`, and `npm audit --omit=dev`.
- ✅ `npm run lint`
- ✅ `npm test`
- ✅ `npm audit --omit=dev`
- Perform manual smoke tests in Slack (help command, channel mention notification, reconnect behavior, tweeting).
- Partial: Hubot boots locally with the new adapter; startup now halts at Slack authentication because placeholder `SLACK_APP_TOKEN` / `SLACK_BOT_TOKEN` were used. Re-run with production Socket Mode tokens to complete verification.

## 6. Follow-Up

- Capture migration instructions in README.
- Determine if any remaining vulnerabilities stem from third-party Hubot scripts and file follow-up issues if they cannot be patched immediately.

## 7. Slack Token Preparation & Sandbox Testing

1. Create (or reuse) a Slack workspace dedicated to testing at <https://slack.com/create>. The free plan is sufficient and avoids touching production data while verifying Socket Mode behavior.
2. Build a Slack app for that workspace via <https://api.slack.com/apps>:
- Enable **Socket Mode**.
- Generate an **App-Level Token** with the `connections:write` scope (this becomes `SLACK_APP_TOKEN`).
- Under **OAuth & Permissions**, add the required bot scopes (`app_mentions:read`, `channels:history`, `chat:write`, `groups:history`, `im:history`, `mpim:history`, `reactions:read`, `reactions:write`, etc.) and reinstall the app to capture the refreshed Bot User OAuth token (`SLACK_BOT_TOKEN`).
3. Store those tokens in a local `.env` (never commit them):

```bash
SLACK_APP_TOKEN=xapp-...
SLACK_BOT_TOKEN=xoxb-...
```

4. Run a local smoke test with the sandbox tokens: `bash bin/hubot --adapter @hubot-friends/hubot-slack`. Confirm the adapter connects (look for "Connected to Slack" in the logs) and exercise key commands (help, channel mention notifications, reconnect behavior, tweeting).
5. After approvals, repeat the token creation steps in the production workspace, update deployment secrets (e.g., Heroku config vars), and re-run the smoke test against production.
10 changes: 1 addition & 9 deletions external-scripts.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
[
"hubot-heroku-keepalive",
"hubot-diagnostics",
"hubot-help",
"hubot-maps",
"hubot-rules",
"hubot-shipit",
"hubot-darts",
"hubot-thank-you",
"hubot-appearin",
"hubot-bikeshed",
"hubot-principles"
"hubot-rules"
]
3 changes: 2 additions & 1 deletion lib/helpers/first-name-for.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module.exports = (organizer) => ({
davin: 'Davin',
atomaka: 'Andrew',
leo: 'Leo'
leo: 'Leo',
'erik.gillespie': 'Erik'
})[organizer]
18 changes: 15 additions & 3 deletions lib/helpers/member-alias-for.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
module.exports = (group) => ({
const aliases = {
'Lansing DevOps Meetup': 'Devs & Ops',
devops: 'Devs & Ops',
'Lansing Ruby Meetup Group': 'Rubyists',
ruby: 'Rubyists',
'Lansing Javascript Meetup': 'JSers',
'Mobile Monday Lansing': 'Mobile Members'
})[group.name]
javascript: 'JSers',
'Mobile Monday Lansing': 'Mobile Members',
mobile: 'Mobile Members'
}

const resolveKey = (group) => {
if (!group) return undefined
if (typeof group === 'string') return group
return group.name || group.id || group.slug || group.meetupUrlName
}

module.exports = (group) => aliases[resolveKey(group)]
15 changes: 12 additions & 3 deletions lib/helpers/organizer-for.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
// The key matches group.id or event.group in the Firebase data model
module.exports = group => ({
const lookup = {
'demo-night': 'erik.gillespie',
javascript: 'erik.gillespie'
}[group.name])
javascript: 'erik.gillespie',
'Lansing Javascript Meetup': 'erik.gillespie'
}

const resolveKey = (group) => {
if (!group) return undefined
if (typeof group === 'string') return group
return group.id || group.slug || group.name || group.meetupUrlName
}

module.exports = (group) => lookup[resolveKey(group)]
30 changes: 29 additions & 1 deletion lib/helpers/shorten-url.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
const TinyURL = require('tinyurl')

const ensureHttps = (url) => {
if (!url) return url
return url.replace(/^http:\/\//i, 'https://')
}

const tryShorten = (longUrl, callback, { retriedWithHttps } = { retriedWithHttps: false }) => {
TinyURL.shorten(longUrl, (...args) => {
const [firstArg, secondArg] = args

const shortUrl = typeof firstArg === 'string' && firstArg
? firstArg
: (typeof secondArg === 'string' && secondArg ? secondArg : null)

const error = (firstArg instanceof Error ? firstArg : null) || (secondArg instanceof Error ? secondArg : null)

if (error || !shortUrl || shortUrl === 'Error') {
if (!retriedWithHttps && /^http:\/\//i.test(longUrl)) {
return tryShorten(ensureHttps(longUrl), callback, { retriedWithHttps: true })
}

callback(ensureHttps(longUrl))
return
}

callback(ensureHttps(shortUrl))
})
}

module.exports = (longUrl, callback) => {
TinyURL.shorten(longUrl, (shortUrl) => callback(shortUrl))
tryShorten(longUrl, callback)
}
105 changes: 105 additions & 0 deletions lib/initializers/heroku-keepalive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Description:
// Keep the Heroku dyno awake during configured hours.
//
// Dependencies:
// N/A
//
// Configuration:
// HUBOT_HEROKU_KEEPALIVE_URL or HEROKU_URL - Required base URL to ping.
// HUBOT_HEROKU_KEEPALIVE_INTERVAL - Minutes between pings (default 5).
// HUBOT_HEROKU_WAKEUP_TIME - Start of uptime window (HH:mm, default 06:00).
// HUBOT_HEROKU_SLEEP_TIME - End of uptime window (HH:mm, default 22:00).
// EXPRESS_USER / EXPRESS_PASSWORD - Optional basic auth credentials.
//
// Commands:
// N/A
//
// Notes:
// Replaces the deprecated hubot-heroku-keepalive external script.
//
// Author:
// Lansing Codes maintainers

const parseTime = (value, fallback) => {
const [hours, minutes] = (value || fallback).split(':').map(number => parseInt(number, 10) || 0)
return { hours: hours % 24, minutes: minutes % 60 }
}

const computeOffsets = (wakeTime, sleepTime) => {
const wakeMinutes = (wakeTime.hours * 60 + wakeTime.minutes) % (24 * 60)
const sleepMinutes = (sleepTime.hours * 60 + sleepTime.minutes) % (24 * 60)
const awakeMinutes = (sleepMinutes - wakeMinutes + 24 * 60) % (24 * 60)

return { wakeMinutes, awakeMinutes }
}

module.exports = robot => {
const url = process.env.HUBOT_HEROKU_KEEPALIVE_URL || process.env.HEROKU_URL

if (!url) {
robot.logger.warn('heroku-keepalive skipped: HUBOT_HEROKU_KEEPALIVE_URL/HEROKU_URL not set')
return
}

const normalizedUrl = url.endsWith('/') ? url : `${url}/`
const intervalMinutes = process.env.HUBOT_HEROKU_KEEPALIVE_INTERVAL
? parseFloat(process.env.HUBOT_HEROKU_KEEPALIVE_INTERVAL)
: 5

if (Number.isNaN(intervalMinutes) || intervalMinutes < 0) {
robot.logger.warn(`Invalid HUBOT_HEROKU_KEEPALIVE_INTERVAL provided: ${process.env.HUBOT_HEROKU_KEEPALIVE_INTERVAL}`)
return
}

if (robot.pingIntervalId) {
clearInterval(robot.pingIntervalId)
}

if (intervalMinutes === 0) {
robot.logger.info('Heroku keepalive disabled (interval set to 0).')
return
}

const wake = parseTime(process.env.HUBOT_HEROKU_WAKEUP_TIME, '6:00')
const sleep = parseTime(process.env.HUBOT_HEROKU_SLEEP_TIME, '22:00')
const { wakeMinutes, awakeMinutes } = computeOffsets(wake, sleep)

const tick = () => {
const now = new Date()
const minutesSinceWake = (now.getHours() * 60 + now.getMinutes() - wakeMinutes + 24 * 60) % (24 * 60)
if (minutesSinceWake >= awakeMinutes) {
robot.logger.info('Skipping Heroku keepalive ping (outside wake window).')
return
}

robot.logger.info('Sending Heroku keepalive ping')

const request = robot.http(`${normalizedUrl}heroku/keepalive`)
if (process.env.EXPRESS_USER && process.env.EXPRESS_PASSWORD) {
request.auth(process.env.EXPRESS_USER, process.env.EXPRESS_PASSWORD)
}
request.post()((error, res, body) => {
if (error) {
robot.logger.error(`Heroku keepalive failed: ${error.message}`)
robot.emit('error', error)
return
}

robot.logger.info(`Heroku keepalive response: ${res && res.statusCode} ${body || ''}`)
})
}

if (intervalMinutes > 0) {
const intervalMs = intervalMinutes * 60 * 1000
tick()
robot.herokuKeepaliveIntervalId = setInterval(tick, intervalMs)
}

const handler = (req, res) => {
res.set('Content-Type', 'text/plain')
res.send('OK')
}

robot.router.post('/heroku/keepalive', handler)
robot.router.get('/heroku/keepalive', handler)
}
Loading