Skip to content

Diagnostic Slack

the token holds admin scopes the app has never called

The security review asks a reasonable question: why does the nightly digest bot hold admin.users:write, files:write, chat:write.public and users:read.email? Nobody knows. Nothing is broken, no error has ever been logged, and every scope on that list was added by somebody who needed it for ten minutes in 2023.

Read-only token Python and Node.js Tests included
A large warehouse filled with lots of shelves
Photo by Lance Chang on Unsplash
The short answer

This is the one note in the section where nothing is failing, so there is no error to read. The script has to prove a negative instead: that a scope on the token has no call site behind it.

It does that by comparing two lists. The first is the complete grant, which Slack returns in the X-OAuth-Scopes header on every Web API response — one auth.test and you have it. The second is the set of methods your code actually calls, which you produce by grepping the repository. Any granted scope that satisfies none of those methods is surplus, ranked by what it would cost if the token leaked: admin.* first, then anything with :write, then email and full message history.

Pruning is not free. Removing a scope, like adding one, only takes effect after a reinstall.

The problem in plain words

Scopes accrete in one direction. A developer hits missing_scope, reads the needed list, adds every scope it names because the error is an OR list and that is not obvious, reinstalls, and moves on. A manifest gets copied from a more ambitious app. A feature is prototyped, abandoned, and its scopes stay. Nothing ever removes one, because removing one requires a reinstall and a reinstall is a change that could break something, and the scope is not breaking anything today.

The result is a bearer credential with far more authority than the code exercises. Slack tokens have no per-call attenuation: there is no way to make a particular request weaker than the token that carries it. So the blast radius of a leak — into a log line, a CI variable, an image layer, a laptop backup — is the full grant, not the part you use. With channels:history the finder can read the workspace's public archive. With users:read.email they can enumerate staff. With an admin.* scope on Grid they can act across the organisation.

What makes this hard to audit honestly is that absence of evidence really is the evidence here. Every other note in this section reads an error. This one reads a silence, and a silence can be produced by a scope that is genuinely unused or by a call site the audit could not see. The script is built to say which of those it is looking at rather than to assert the stronger claim.

missing_scopeonceneeded is an ORlistAll of themaddedreinstall, move onFeatureabandonedscopes stayToken leaks toa logfull grant, noattenuationArchivereadablenothing was everbroken
Every step here succeeds. That is the whole problem: nothing in the loop ever removes a scope, because removing one needs a reinstall and the scope is not breaking anything today.

Why it happens

X-OAuth-Scopes is the grant, and the config page is not. The header comes back on every response and describes the token in your hand. The scope list on the app configuration page describes what the next install will request. On an app that has not been reinstalled since the last edit, those two are different documents.

needed is an OR list, and that is where over-scoping starts. When Slack says a call needs channels:history or groups:history or im:history, any one of them will do. Adding all three, which is the natural reading of a comma-separated error field, triples the archive a leaked token opens.

chat:write.public is much larger than it sounds. It removes the requirement to be invited: the app can post into any public channel in the workspace. chat:write plus a deliberate invitation gives you a bot that can only speak where somebody asked it to.

Removing a scope needs a reinstall too. The token is a snapshot of the grant at install time in both directions. Pruning the list and redeploying changes nothing until the app is installed again and the new token replaces the old one everywhere it is stored.

Two apps beat one over-scoped app. If some job genuinely needs to read every message and another only posts a digest, those are two installs with two tokens and two rotation schedules. One token that can do both is one leak away from doing both for somebody else.

The fix, as a flow

There is no error anywhere in this one, so the script compares two lists instead: the grant Slack returns in a header, and the methods the code actually calls. A method it cannot map is reported rather than ignored, because ignoring it turns a gap in the audit into a confident instruction to delete something.

Grant against call sitesheader versus inventoryadmin, no call siteacts across the orgA write on a readershould hold noneEmail and historystaff list, full archiveMethod not in the tablecannot conclude yetEvery scope has a callerleast privilege holds
An unused emoji read is tidying. An unused admin scope is an incident waiting for a laptop backup, and flattening the two into one list is how the real finding gets ignored.

How to fix it

Read the whole grant off one response

One auth.test, and the answer is in the X-OAuth-Scopes response header rather than the body. That is the complete current scope list for the credential that is actually deployed, which is the only list worth auditing.

Produce the call inventory from the code, not from memory

Grep the repository for Slack method names and keep the distinct ones. The script prints the command if you have not run it. This list is the entire basis for the conclusion, so an incomplete grep produces a confidently wrong report — which is why the next step exists.

Map each called method to the scopes that would satisfy it

Per method the requirement is an OR list, so a granted scope is justified if it appears in the option set of any method the app calls. Methods the table does not recognise are counted separately and reported, because each one makes the justified set a lower bound.

Rank what is left by what it would cost

Surplus is not uniform. An unused admin.* scope on a routine integration is a different finding from an unused emoji:read. The ranking is admin.*, then any :write, then email and profile access, then full history, then ordinary reads — and only the first four change the exit code.

Report the gaps as well, and send them elsewhere

A method the app calls with none of its scopes granted is the opposite finding, and it belongs in the note about missing_scope rather than this one. Printing it here is still worth doing: it is a fast check that the inventory and the grant describe the same app.

Prune, reinstall, replace the token

Cut the scope list in OAuth & Permissions to what the inventory justifies, reinstall the app, and replace the stored token everywhere. For a distributed app, every workspace re-authorises on its own schedule, so plan the prune as a migration rather than a deploy.

How to check it worked

After the prune and the reinstall, re-run against the new token. The granted list should be shorter, and nothing above the ordinary-read tier should remain unjustified.

python3 slack_scope_surplus.py --calls calls.txt
# granted 7, justified 7, surplus 0, gaps 0
# 0 surplus scope(s) above the ordinary-read tier

The full code

One GET, and the interesting value is a response header rather than the body. Two pure functions do the reasoning: justify splits the granted list against the methods the app calls and hands back the unrecognised methods separately, because those are what make the answer a lower bound; and rank orders whatever is left by how much it would cost if the token were found in a log.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 20 Slack fixes, free and open source.
slack_scope_surplus.py
"""Find Slack scopes on the deployed token that no call site justifies.

Read only, and unusually, nothing here is failing: the script proves a negative
rather than reading an error. One GET, and the answer is in a response header.
The prune and the reinstall are printed for a human to run.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_scope_surplus")

API = "https://slack.com/api/"

# Per method, the scopes that would each satisfy it: an OR list, not an AND list.
# Deliberately incomplete -- Slack has hundreds of methods, and a method missing
# from this table is reported rather than assumed harmless.
SCOPES_FOR_METHOD = {
    "auth.test": (),
    "team.info": ("team:read",),
    "conversations.list": ("channels:read", "groups:read", "im:read", "mpim:read"),
    "conversations.info": ("channels:read", "groups:read", "im:read", "mpim:read"),
    "conversations.members": ("channels:read", "groups:read"),
    "conversations.history": ("channels:history", "groups:history",
                              "im:history", "mpim:history"),
    "conversations.replies": ("channels:history", "groups:history",
                              "im:history", "mpim:history"),
    "users.list": ("users:read",),
    "users.info": ("users:read",),
    "users.lookupByEmail": ("users:read.email",),
    "users.conversations": ("channels:read", "groups:read", "im:read", "mpim:read"),
    "chat.postMessage": ("chat:write", "chat:write.public"),
    "chat.update": ("chat:write",),
    "files.list": ("files:read",),
    "files.info": ("files:read",),
    "emoji.list": ("emoji:read",),
    "usergroups.list": ("usergroups:read",),
    "reactions.get": ("reactions:read",),
    "pins.list": ("pins:read",),
    "bookmarks.list": ("bookmarks:read",),
    "search.messages": ("search:read",),
    "admin.teams.list": ("admin.teams:read",),
    "admin.users.list": ("admin.users:read",),
    "admin.conversations.search": ("admin.conversations:read",),
}

# Tier, and why it matters if the token is ever found somewhere it should not be.
TIERS = (
    ("admin", "acts across the organisation, not just this workspace"),
    ("write", "changes the workspace; a read-only integration should hold none"),
    ("pii", "enumerates staff identities and addresses"),
    ("archive", "opens the full message archive of every conversation it covers"),
    ("read", "ordinary read access; surplus is untidy rather than dangerous"),
)
SERIOUS = ("admin", "write", "pii", "archive")


def _tier(scope):
    if scope.startswith("admin."):
        return "admin"
    if ":write" in scope:
        return "write"
    if scope in ("users:read.email", "users.profile:read"):
        return "pii"
    if scope.endswith(":history"):
        return "archive"
    return "read"


def justify(granted, methods):
    """Split a granted scope list against the methods the app actually calls. Pure.

    Returns (justified, surplus, gaps, unknown). `justified` is every granted
    scope that satisfies at least one called method; `surplus` is the rest;
    `gaps` are called methods with none of their scopes granted; `unknown` are
    methods absent from the table, which make `surplus` a candidate list rather
    than a verdict.
    """
    granted = sorted(set(granted))
    justified, gaps, unknown = set(), [], []
    for method in sorted(set(methods)):
        options = SCOPES_FOR_METHOD.get(method)
        if options is None:
            unknown.append(method)
            continue
        if not options:
            continue
        hit = [s for s in options if s in granted]
        if hit:
            justified.update(hit)
        else:
            gaps.append((method, list(options)))
    surplus = [s for s in granted if s not in justified]
    return (sorted(justified), surplus, gaps, unknown)


def rank(scopes):
    """Order surplus scopes by what they would cost if the token leaked. Pure."""
    order = {name: i for i, (name, _why) in enumerate(TIERS)}
    why = dict(TIERS)
    out = [(s, _tier(s), why[_tier(s)]) for s in scopes]
    return sorted(out, key=lambda row: (order[row[1]], row[0]))


def granted_scopes(session, token):
    """The complete grant, from the header Slack puts on every response."""
    r = session.get(API + "auth.test", headers={"Authorization": "Bearer " + token},
                    timeout=30)
    header = r.headers.get("X-OAuth-Scopes")
    try:
        body = r.json()
    except ValueError:
        body = {"ok": False, "error": "unparseable_body"}
    scopes = [s.strip() for s in (header or "").split(",") if s.strip()]
    return (scopes, header is not None, body)


def load_calls(path):
    """One Slack method name per line; comments and blanks ignored."""
    lines = open(path, encoding="utf-8").read().splitlines()
    return [l.strip() for l in lines if l.strip() and not l.strip().startswith("#")]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--calls", help="file of Slack method names this app actually calls")
    args = ap.parse_args()

    token = os.environ.get("SLACK_BOT_TOKEN")
    if not token:
        log.error("set SLACK_BOT_TOKEN (the token the app actually deploys with)")
        return 2
    if not args.calls:
        log.error("pass --calls with the methods this app calls. Build it with:")
        log.error("  grep -rhoE 'slack[./][a-z]+\\.[a-zA-Z]+' . | sort -u")
        log.error("A surplus scope is only surplus relative to a call inventory, so "
                  "this script will not guess one.")
        return 2

    s = requests.Session()
    scopes, had_header, body = granted_scopes(s, token)
    if body.get("ok") is not True:
        log.error("auth.test answered error=%s; fix the credential first",
                  body.get("error") or "?")
        return 2
    if not had_header:
        log.error("no X-OAuth-Scopes header on the response. Something between this "
                  "script and Slack is stripping it, and the audit cannot proceed")
        return 2

    methods = load_calls(args.calls)
    justified, surplus, gaps, unknown = justify(scopes, methods)

    log.info("granted %d, justified %d, surplus %d, gaps %d",
             len(scopes), len(justified), len(surplus), len(gaps))

    serious = 0
    for scope, tier, why in rank(surplus):
        line = "%-9s %-26s %s" % (tier, scope, why)
        if tier in SERIOUS:
            serious += 1
            log.warning(line)
        else:
            log.info(line)

    for method, options in gaps:
        log.warning("%-9s %-26s none of %s is granted; that is a missing scope, "
                    "not a surplus one", "gap", method, ", ".join(options))

    if unknown:
        log.warning("%-9s %d method(s) are not in this script's table: %s",
                    "unmapped", len(unknown), ", ".join(unknown))
        log.warning("  each one may justify a scope listed above, so treat the "
                    "surplus list as candidates until they are mapped")

    if serious:
        log.warning("repair: prune OAuth & Permissions to the justified set, then "
                    "reinstall -- removing a scope needs a reinstall too")
        log.warning("repair: where broad read access is genuinely needed, split it "
                    "into a second app so the wide token has its own blast radius")

    log.info("%d surplus scope(s) above the ordinary-read tier", serious)
    return 1 if serious else 0


if __name__ == "__main__":
    sys.exit(main())
slack-scope-surplus.mjs
/**
 * Find Slack scopes on the deployed token that no call site justifies.
 *
 * Read only, and unusually, nothing here is failing: the script proves a
 * negative rather than reading an error. One GET, and the answer is in a
 * response header. The prune and the reinstall are printed for a human to run.
 */
import { readFile } from 'node:fs/promises';

const API = 'https://slack.com/api/';

// Per method, the scopes that would each satisfy it: an OR list, not an AND
// list. Deliberately incomplete -- Slack has hundreds of methods, and a method
// missing from this table is reported rather than assumed harmless.
const SCOPES_FOR_METHOD = {
  'auth.test': [],
  'team.info': ['team:read'],
  'conversations.list': ['channels:read', 'groups:read', 'im:read', 'mpim:read'],
  'conversations.info': ['channels:read', 'groups:read', 'im:read', 'mpim:read'],
  'conversations.members': ['channels:read', 'groups:read'],
  'conversations.history': ['channels:history', 'groups:history', 'im:history', 'mpim:history'],
  'conversations.replies': ['channels:history', 'groups:history', 'im:history', 'mpim:history'],
  'users.list': ['users:read'],
  'users.info': ['users:read'],
  'users.lookupByEmail': ['users:read.email'],
  'users.conversations': ['channels:read', 'groups:read', 'im:read', 'mpim:read'],
  'chat.postMessage': ['chat:write', 'chat:write.public'],
  'chat.update': ['chat:write'],
  'files.list': ['files:read'],
  'files.info': ['files:read'],
  'emoji.list': ['emoji:read'],
  'usergroups.list': ['usergroups:read'],
  'reactions.get': ['reactions:read'],
  'pins.list': ['pins:read'],
  'bookmarks.list': ['bookmarks:read'],
  'search.messages': ['search:read'],
  'admin.teams.list': ['admin.teams:read'],
  'admin.users.list': ['admin.users:read'],
  'admin.conversations.search': ['admin.conversations:read'],
};

// Tier, and why it matters if the token is ever found somewhere it should not be.
const TIERS = [
  ['admin', 'acts across the organisation, not just this workspace'],
  ['write', 'changes the workspace; a read-only integration should hold none'],
  ['pii', 'enumerates staff identities and addresses'],
  ['archive', 'opens the full message archive of every conversation it covers'],
  ['read', 'ordinary read access; surplus is untidy rather than dangerous'],
];
const SERIOUS = new Set(['admin', 'write', 'pii', 'archive']);

function tierOf(scope) {
  if (scope.startsWith('admin.')) return 'admin';
  if (scope.includes(':write')) return 'write';
  if (scope === 'users:read.email' || scope === 'users.profile:read') return 'pii';
  if (scope.endsWith(':history')) return 'archive';
  return 'read';
}

/**
 * Split a granted scope list against the methods the app actually calls. Pure.
 * Returns [justified, surplus, gaps, unknown]. `unknown` are methods absent from
 * the table, which make `surplus` a candidate list rather than a verdict.
 */
export function justify(granted, methods) {
  const grantedSet = new Set(granted);
  const sortedGranted = [...grantedSet].sort();
  const justified = new Set();
  const gaps = [];
  const unknown = [];

  for (const method of [...new Set(methods)].sort()) {
    const options = SCOPES_FOR_METHOD[method];
    if (options === undefined) {
      unknown.push(method);
      continue;
    }
    if (options.length === 0) continue;
    const hit = options.filter((s) => grantedSet.has(s));
    if (hit.length) hit.forEach((s) => justified.add(s));
    else gaps.push([method, options]);
  }

  const surplus = sortedGranted.filter((s) => !justified.has(s));
  return [[...justified].sort(), surplus, gaps, unknown];
}

/** Order surplus scopes by what they would cost if the token leaked. Pure. */
export function rank(scopes) {
  const order = new Map(TIERS.map(([name], i) => [name, i]));
  const why = new Map(TIERS);
  return scopes
    .map((s) => [s, tierOf(s), why.get(tierOf(s))])
    .sort((a, b) => (order.get(a[1]) - order.get(b[1])) || a[0].localeCompare(b[0]));
}

async function grantedScopes(token) {
  const res = await fetch(API + 'auth.test', {
    headers: { Authorization: `Bearer ${token}` },
  });
  const header = res.headers.get('x-oauth-scopes');
  let body;
  try {
    body = await res.json();
  } catch {
    body = { ok: false, error: 'unparseable_body' };
  }
  const scopes = (header ?? '').split(',').map((s) => s.trim()).filter(Boolean);
  return [scopes, header !== null, body];
}

async function main() {
  const args = process.argv.slice(2);
  const i = args.indexOf('--calls');
  const callsPath = i === -1 ? null : args[i + 1];

  const token = process.env.SLACK_BOT_TOKEN;
  if (!token) {
    console.error('set SLACK_BOT_TOKEN (the token the app actually deploys with)');
    process.exitCode = 2;
    return;
  }
  if (!callsPath) {
    console.error('pass --calls with the methods this app calls. Build it with:');
    console.error("  grep -rhoE 'slack[./][a-z]+\\.[a-zA-Z]+' . | sort -u");
    console.error('A surplus scope is only surplus relative to a call inventory, ' +
                  'so this script will not guess one.');
    process.exitCode = 2;
    return;
  }

  const [scopes, hadHeader, body] = await grantedScopes(token);
  if (body?.ok !== true) {
    console.error(`auth.test answered error=${body?.error ?? '?'}; fix the credential first`);
    process.exitCode = 2;
    return;
  }
  if (!hadHeader) {
    console.error('no X-OAuth-Scopes header on the response. Something between this ' +
                  'script and Slack is stripping it, and the audit cannot proceed');
    process.exitCode = 2;
    return;
  }

  const methods = (await readFile(callsPath, 'utf8'))
    .split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
  const [justified, surplus, gaps, unknown] = justify(scopes, methods);

  console.log(`granted ${scopes.length}, justified ${justified.length}, surplus ` +
              `${surplus.length}, gaps ${gaps.length}`);

  let serious = 0;
  for (const [scope, tier, why] of rank(surplus)) {
    const line = `${tier.padEnd(9)} ${scope.padEnd(26)} ${why}`;
    if (SERIOUS.has(tier)) {
      serious += 1;
      console.warn(line);
    } else {
      console.log(line);
    }
  }

  for (const [method, options] of gaps) {
    console.warn(`${'gap'.padEnd(9)} ${method.padEnd(26)} none of ${options.join(', ')} ` +
                 'is granted; that is a missing scope, not a surplus one');
  }

  if (unknown.length) {
    console.warn(`${'unmapped'.padEnd(9)} ${unknown.length} method(s) are not in this ` +
                 `script's table: ${unknown.join(', ')}`);
    console.warn('  each one may justify a scope listed above, so treat the surplus ' +
                 'list as candidates until they are mapped');
  }

  if (serious) {
    console.warn('repair: prune OAuth & Permissions to the justified set, then ' +
                 'reinstall -- removing a scope needs a reinstall too');
    console.warn('repair: where broad read access is genuinely needed, split it into ' +
                 'a second app so the wide token has its own blast radius');
  }

  console.log(`${serious} surplus scope(s) above the ordinary-read tier`);
  process.exitCode = serious ? 1 : 0;
}

// Only run when invoked directly, so importing this module in the tests does not
// execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The trap in an audit that proves a negative is the method it has never heard of: silently ignoring it turns “we could not check” into “it is surplus”, and the report becomes a confident instruction to remove a scope something depends on. So the tests pin that an unmapped method is returned separately, and that a scope satisfied by an OR list is counted as justified by whichever option was actually granted.

test_slack_scope_surplus.py
from slack_scope_surplus import justify, rank


def test_a_scope_with_no_call_site_is_surplus():
    justified, surplus, gaps, unknown = justify(
        ["chat:write", "admin.users:read"], ["chat.postMessage"])
    assert justified == ["chat:write"]
    assert surplus == ["admin.users:read"]
    assert gaps == [] and unknown == []


def test_an_or_list_is_justified_by_whichever_option_was_granted():
    justified, surplus, _, _ = justify(["groups:history"], ["conversations.history"])
    assert justified == ["groups:history"]
    assert surplus == []


def test_a_method_the_table_does_not_know_is_reported_not_ignored():
    justified, surplus, gaps, unknown = justify(["pins:read"], ["pins.add"])
    assert unknown == ["pins.add"]
    assert surplus == ["pins:read"]
    assert gaps == []


def test_a_called_method_with_no_granted_scope_is_a_gap_not_a_surplus():
    _, surplus, gaps, _ = justify(["chat:write"], ["chat.postMessage", "users.list"])
    assert surplus == []
    assert gaps == [("users.list", ["users:read"])]


def test_a_method_that_needs_no_scope_justifies_nothing():
    justified, surplus, _, _ = justify(["team:read"], ["auth.test"])
    assert justified == []
    assert surplus == ["team:read"]


def test_ranking_puts_admin_scopes_first_and_plain_reads_last():
    ordered = [row[0] for row in rank(
        ["emoji:read", "channels:history", "admin.users:write", "files:write",
         "users:read.email"])]
    assert ordered == ["admin.users:write", "files:write", "users:read.email",
                       "channels:history", "emoji:read"]


def test_write_scopes_are_flagged_even_when_they_look_narrow():
    assert dict((s, t) for s, t, _ in rank(["chat:write.public"]))["chat:write.public"] == "write"


def test_history_is_ranked_as_an_archive_scope():
    assert rank(["im:history"])[0][1] == "archive"


def test_ordinary_reads_carry_a_gentler_explanation():
    _, _, why = rank(["emoji:read"])[0]
    assert "untidy" in why
slack-scope-surplus.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { justify, rank } from './slack-scope-surplus.mjs';

test('a scope with no call site is surplus', () => {
  const [justified, surplus, gaps, unknown] = justify(
    ['chat:write', 'admin.users:read'], ['chat.postMessage']);
  assert.deepEqual(justified, ['chat:write']);
  assert.deepEqual(surplus, ['admin.users:read']);
  assert.deepEqual(gaps, []);
  assert.deepEqual(unknown, []);
});

test('an or list is justified by whichever option was granted', () => {
  const [justified, surplus] = justify(['groups:history'], ['conversations.history']);
  assert.deepEqual(justified, ['groups:history']);
  assert.deepEqual(surplus, []);
});

test('a method the table does not know is reported not ignored', () => {
  const [, surplus, gaps, unknown] = justify(['pins:read'], ['pins.add']);
  assert.deepEqual(unknown, ['pins.add']);
  assert.deepEqual(surplus, ['pins:read']);
  assert.deepEqual(gaps, []);
});

test('a called method with no granted scope is a gap not a surplus', () => {
  const [, surplus, gaps] = justify(['chat:write'], ['chat.postMessage', 'users.list']);
  assert.deepEqual(surplus, []);
  assert.deepEqual(gaps, [['users.list', ['users:read']]]);
});

test('a method that needs no scope justifies nothing', () => {
  const [justified, surplus] = justify(['team:read'], ['auth.test']);
  assert.deepEqual(justified, []);
  assert.deepEqual(surplus, ['team:read']);
});

test('ranking puts admin scopes first and plain reads last', () => {
  const ordered = rank(['emoji:read', 'channels:history', 'admin.users:write',
    'files:write', 'users:read.email']).map((row) => row[0]);
  assert.deepEqual(ordered, ['admin.users:write', 'files:write', 'users:read.email',
    'channels:history', 'emoji:read']);
});

test('write scopes are flagged even when they look narrow', () => {
  assert.equal(rank(['chat:write.public'])[0][1], 'write');
});

test('history is ranked as an archive scope', () => {
  assert.equal(rank(['im:history'])[0][1], 'archive');
});

test('ordinary reads carry a gentler explanation', () => {
  assert.match(rank(['emoji:read'])[0][2], /untidy/);
});

FAQ

How can a script prove a scope is unused?

It cannot, on its own, and it should not claim to. What it can do is compare the grant against a stated inventory of the methods your code calls, and report any scope that inventory does not justify. That is why the script refuses to run without a call list and why it reports methods it could not map: the conclusion is only as good as the inventory, and saying so is part of the output.

Does removing a scope really need a reinstall?

Yes, in both directions. A token is a snapshot of the grant at the moment it was issued, so pruning the scope list changes what the next installation will request and leaves the deployed token exactly as it was. The scope is only gone once the app has been installed again and the new token has replaced the old one everywhere it is stored.

Which surplus scopes are actually worth acting on?

Anything under admin.*, because it acts across a Grid organisation; anything containing :write on an integration that only reads; users:read.email, because it turns a leak into a staff directory; and the :history family, because it opens the full message archive of every conversation it covers. An unused emoji:read is worth tidying and is not worth an incident.

Is chat:write.public really that different from chat:write?

Yes. chat:write lets the app post in conversations it has been added to, so somebody made a decision for each one. chat:write.public removes that step entirely and lets the app post in any public channel in the workspace without being invited. If the app posts to a fixed set of channels, the invitation is the cheaper and much narrower answer.

What if two teams share one app to avoid a second install?

Then one token carries the union of both teams' needs, and a leak carries both. Splitting them into two apps costs a second installation and buys two smaller blast radiuses, two independent rotation schedules, and the ability to revoke one without breaking the other. That is usually the right trade the moment either half needs a history or admin scope.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.