@supersuit/suggest-a-change 0.1.13 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +162 -7
  3. package/dist/client/adapters.d.ts +27 -0
  4. package/dist/client/context.d.ts +61 -0
  5. package/dist/client/context.js +279 -0
  6. package/dist/client/detail.d.ts +2 -2
  7. package/dist/client/detail.js +12 -8
  8. package/dist/client/entry.js +2 -1
  9. package/dist/client/index.d.ts +2 -0
  10. package/dist/client/index.js +1 -0
  11. package/dist/client/mine.js +4 -2
  12. package/dist/client/runtime.js +3 -0
  13. package/dist/client/seen.js +9 -7
  14. package/dist/client/sheet.js +64 -4
  15. package/dist/client/words.d.ts +10 -1
  16. package/dist/client/words.js +11 -2
  17. package/dist/contract.d.mts +32 -22
  18. package/dist/contract.mjs +127 -37
  19. package/dist/github/api.d.ts +100 -0
  20. package/dist/github/api.js +164 -0
  21. package/dist/github/compose.d.ts +159 -0
  22. package/dist/github/compose.js +312 -0
  23. package/dist/github/filer.d.ts +72 -0
  24. package/dist/github/filer.js +89 -0
  25. package/dist/github/hosts.d.ts +41 -0
  26. package/dist/github/hosts.js +154 -0
  27. package/dist/github/index.d.ts +12 -0
  28. package/dist/github/index.js +9 -0
  29. package/dist/github/privacy.d.ts +34 -0
  30. package/dist/github/privacy.js +142 -0
  31. package/dist/github/relay.d.ts +159 -0
  32. package/dist/github/relay.js +734 -0
  33. package/dist/server/geo.d.ts +17 -0
  34. package/dist/server/geo.js +35 -0
  35. package/dist/server/handlers.d.ts +54 -3
  36. package/dist/server/handlers.js +143 -41
  37. package/dist/server/index.d.ts +3 -1
  38. package/dist/server/index.js +1 -0
  39. package/package.json +9 -2
  40. package/src/client/adapters.ts +18 -1
  41. package/src/client/context.ts +243 -0
  42. package/src/client/detail.tsx +13 -8
  43. package/src/client/entry.tsx +2 -1
  44. package/src/client/index.ts +2 -0
  45. package/src/client/mine.tsx +7 -3
  46. package/src/client/runtime.ts +3 -0
  47. package/src/client/seen.ts +8 -6
  48. package/src/client/sheet.tsx +78 -4
  49. package/src/client/styles.css +5 -0
  50. package/src/client/words.ts +15 -2
  51. package/src/contract.mjs +110 -37
  52. package/src/github/api.ts +169 -0
  53. package/src/github/compose.ts +362 -0
  54. package/src/github/filer.ts +96 -0
  55. package/src/github/hosts.ts +117 -0
  56. package/src/github/index.ts +15 -0
  57. package/src/github/privacy.ts +131 -0
  58. package/src/github/relay.ts +637 -0
  59. package/src/server/geo.ts +32 -0
  60. package/src/server/handlers.ts +158 -45
  61. package/src/server/index.ts +3 -1
@@ -0,0 +1,131 @@
1
+ // THE PRIVACY CATCH (suggestions-as-github-issues design, section 5). Runs over everything a person SAID before any of it
2
+ // becomes an issue: the words, the transcript and the follow-ups. Screenshots are not read for text in this version; the
3
+ // sheet's list is what tells the sender they are going. A hit holds the report for the owner; a credential is never
4
+ // filed as it is, whatever the tap (`mayFileAsIs`).
5
+
6
+ export type HitKind = 'credential' | 'email' | 'phone' | 'person'
7
+ export interface Hit { kind: HitKind; text: string; field: string }
8
+ export interface Person { name: string; aliases?: string[] }
9
+ export interface CatchOptions {
10
+ /** Third parties: anyone in the workspace's people records other than the sender and the owner. */
11
+ people?: Person[]
12
+ /** Secrets the app knows, such as the sender's own account key; one under 12 characters is a name, not a secret, and is not looked for. */
13
+ secrets?: string[]
14
+ }
15
+ /** What the catch reads: report fields, plus a `transcript` the Mac wrote. */
16
+ export interface Readable { words?: unknown; transcript?: unknown; followUps?: unknown; [k: string]: unknown }
17
+
18
+ const CREDENTIALS: RegExp[] = [
19
+ /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g,
20
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
21
+ /\bsk-ant-[A-Za-z0-9_-]{20,}/g,
22
+ /\bsk-(?!ant-)[A-Za-z0-9_-]{20,}/g,
23
+ /\bAKIA[0-9A-Z]{16}\b/g,
24
+ /\bAIza[0-9A-Za-z_-]{35}/g,
25
+ /\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
26
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
27
+ ]
28
+ // A long random value after a name that says it is secret. The value alone is the hit, so "token=" survives redaction.
29
+ const ASSIGNED = /\b(?:api[_-]?key|key|token|secret|password|passwd)\s*[=:]\s*["']?([A-Za-z0-9+/_=-]{20,})/gi
30
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
31
+ const PHONES: RegExp[] = [
32
+ /(?<![\w.])(?:\+?1[\s.-]?)?(?:\(\d{3}\)|\d{3})[\s.-]?\d{3}[\s.-]?\d{4}(?![\w.])/g,
33
+ /(?<![\w.])\+\d{8,15}(?![\w.])/g,
34
+ ]
35
+
36
+ /** Shannon entropy in bits per character: random keys score high, "aaaa..." and ordinary words low. */
37
+ function entropy(s: string): number {
38
+ const n = new Map<string, number>()
39
+ for (const ch of s) n.set(ch, (n.get(ch) ?? 0) + 1)
40
+ let e = 0
41
+ for (const c of n.values()) { const p = c / s.length; e -= p * Math.log2(p) }
42
+ return e
43
+ }
44
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
45
+
46
+ /** Text as it will read once rendered: compatibility forms folded (NFKC, so fullwidth letters are letters) and invisible
47
+ * format characters removed (zero-width spaces and joiners, soft hyphens, direction marks), so a split token still matches. */
48
+ export const normalize = (s: string) => s.normalize('NFKC').replace(/\p{Cf}/gu, '')
49
+
50
+ /** EVERY string that reaches the issue, with the path it sits at: the words, the transcript, the follow-ups, where, every
51
+ * diagnostic, the names given (filerName, appName), the contact's handle, and every part of the context (each item's
52
+ * label, value and why, and the routes, taps and errors). Each is normalized before it is read. */
53
+ function fields(r: Readable): [string, string][] {
54
+ const out: [string, string][] = []
55
+ const at = (path: string, v: unknown) => { if (typeof v === 'string' && v) out.push([path, normalize(v)]) }
56
+ const obj = (v: unknown) => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null)
57
+ at('words', r.words)
58
+ at('transcript', r.transcript)
59
+ if (Array.isArray(r.followUps)) r.followUps.forEach((f, i) => at(`followUps.${i}.words`, obj(f)?.words))
60
+ const where = obj(r.where)
61
+ at('where.screen', where?.screen); at('where.route', where?.route)
62
+ for (const [k, v] of Object.entries(obj(r.diagnostics) ?? {})) at(`diagnostics.${k}`, v)
63
+ at('filerName', r.filerName); at('appName', r.appName)
64
+ at('who.contact.handle', obj(obj(r.who)?.contact)?.handle)
65
+ // The names of the files, which the issue shows as link text.
66
+ if (Array.isArray(r.files)) r.files.forEach((f, i) => at(`files.${i}.name`, typeof f === 'string' ? f : obj(f)?.name))
67
+ if (Array.isArray(r.files)) r.files.forEach((f, i) => at(`files.${i}.name`, typeof f === 'string' ? f : obj(f)?.name))
68
+ const ctx = obj(r.context)
69
+ if (ctx) {
70
+ for (const [list, keys] of [['items', ['label', 'value', 'why']], ['routes', ['route', 'at']], ['taps', ['label', 'at']], ['errors', ['message', 'frame', 'at']]] as const) {
71
+ const v = ctx[list]
72
+ if (Array.isArray(v)) v.forEach((x, i) => { for (const k of keys) at(`context.${list}.${i}.${k}`, obj(x)?.[k]) })
73
+ }
74
+ }
75
+ return out
76
+ }
77
+ const digits = (s: string) => s.replace(/\D/g, '')
78
+
79
+ export function privacyCatch(report: Readable, { people = [], secrets = [] }: CatchOptions = {}): { hits: Hit[] } {
80
+ const hits: Hit[] = []
81
+ const seen = new Set<string>()
82
+ // The sender's own way to be reached is theirs to share: it is never a hit, while anyone else's still is. An email
83
+ // or phone inside the contact itself (its handle) is the sender's too; anywhere else it is a hit.
84
+ const own = (report.who as { contact?: { email?: unknown; phone?: unknown } } | undefined)?.contact
85
+ const ownEmail = typeof own?.email === 'string' ? normalize(own.email).toLowerCase() : null
86
+ const ownPhone = typeof own?.phone === 'string' && digits(own.phone).length >= 7 ? digits(own.phone) : null
87
+ const add = (kind: HitKind, text: string, field: string) => {
88
+ if (kind === 'email' && ownEmail && text.toLowerCase() === ownEmail) return
89
+ if (kind === 'phone' && ownPhone && digits(text) === ownPhone) return
90
+ if ((kind === 'email' || kind === 'phone') && field === 'who.contact.handle') return
91
+ const k = `${field}\u0000${text}`
92
+ if (seen.has(k)) return
93
+ // A value already caught as a credential is not caught again as something smaller inside it.
94
+ if (hits.some((h) => h.field === field && h.kind === 'credential' && h.text.includes(text))) return
95
+ seen.add(k); hits.push({ kind, text, field })
96
+ }
97
+ const keys = secrets.filter((k) => typeof k === 'string' && k.length >= 12).map(normalize)
98
+ const names = people.flatMap((p) => [p.name, ...(p.aliases ?? [])]).filter((n) => typeof n === 'string' && n.trim().split(/\s+/).length >= 2).map(normalize)
99
+ for (const [field, text] of fields(report)) {
100
+ for (const re of CREDENTIALS) for (const m of text.matchAll(re)) add('credential', m[0], field)
101
+ for (const m of text.matchAll(ASSIGNED)) if (entropy(m[1]) >= 3) add('credential', m[1], field)
102
+ for (const k of keys) if (text.includes(k)) add('credential', k, field)
103
+ for (const m of text.matchAll(EMAIL)) add('email', m[0], field)
104
+ for (const re of PHONES) for (const m of text.matchAll(re)) add('phone', m[0].trim(), field)
105
+ for (const n of names) {
106
+ const re = new RegExp(`(?<![\\p{L}\\p{N}])${escape(n.trim()).replace(/\s+/g, '\\s+')}(?![\\p{L}\\p{N}])`, 'giu')
107
+ for (const m of text.matchAll(re)) add('person', m[0], field)
108
+ }
109
+ }
110
+ return { hits }
111
+ }
112
+
113
+ /** A copy of the report with every hit replaced by `[removed]` in its own field, wherever it appears there. A field with a
114
+ * hit comes back normalized (the form the catch read), so a hit split by an invisible character is removed too. */
115
+ export function redact<T extends Readable>(report: T, hits: Hit[]): T {
116
+ const out = JSON.parse(JSON.stringify(report)) as Record<string, unknown>
117
+ for (const field of [...new Set(hits.map((h) => h.field))]) {
118
+ const path = field.split('.')
119
+ let at: unknown = out
120
+ for (const p of path.slice(0, -1)) at = at && typeof at === 'object' ? (at as Record<string, unknown>)[p] : undefined
121
+ const last = path[path.length - 1]
122
+ if (!at || typeof at !== 'object' || typeof (at as Record<string, unknown>)[last] !== 'string') continue
123
+ const mine = hits.filter((h) => h.field === field).map((h) => h.text).sort((a, b) => b.length - a.length)
124
+ ;(at as Record<string, unknown>)[last] = mine.reduce((t, h) => t.split(h).join('[removed]'), normalize((at as Record<string, string>)[last]))
125
+ }
126
+ return out as T
127
+ }
128
+
129
+
130
+ /** No credential among the hits: the owner may file it as it is. A credential only ever files without the flagged part. */
131
+ export const mayFileAsIs = (hits: Hit[]): boolean => !hits.some((h) => h.kind === 'credential')