@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.
- package/CHANGELOG.md +23 -0
- package/README.md +162 -7
- package/dist/client/adapters.d.ts +27 -0
- package/dist/client/context.d.ts +61 -0
- package/dist/client/context.js +279 -0
- package/dist/client/detail.d.ts +2 -2
- package/dist/client/detail.js +12 -8
- package/dist/client/entry.js +2 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/client/mine.js +4 -2
- package/dist/client/runtime.js +3 -0
- package/dist/client/seen.js +9 -7
- package/dist/client/sheet.js +64 -4
- package/dist/client/words.d.ts +10 -1
- package/dist/client/words.js +11 -2
- package/dist/contract.d.mts +32 -22
- package/dist/contract.mjs +127 -37
- package/dist/github/api.d.ts +100 -0
- package/dist/github/api.js +164 -0
- package/dist/github/compose.d.ts +159 -0
- package/dist/github/compose.js +312 -0
- package/dist/github/filer.d.ts +72 -0
- package/dist/github/filer.js +89 -0
- package/dist/github/hosts.d.ts +41 -0
- package/dist/github/hosts.js +154 -0
- package/dist/github/index.d.ts +12 -0
- package/dist/github/index.js +9 -0
- package/dist/github/privacy.d.ts +34 -0
- package/dist/github/privacy.js +142 -0
- package/dist/github/relay.d.ts +159 -0
- package/dist/github/relay.js +734 -0
- package/dist/server/geo.d.ts +17 -0
- package/dist/server/geo.js +35 -0
- package/dist/server/handlers.d.ts +54 -3
- package/dist/server/handlers.js +143 -41
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/package.json +9 -2
- package/src/client/adapters.ts +18 -1
- package/src/client/context.ts +243 -0
- package/src/client/detail.tsx +13 -8
- package/src/client/entry.tsx +2 -1
- package/src/client/index.ts +2 -0
- package/src/client/mine.tsx +7 -3
- package/src/client/runtime.ts +3 -0
- package/src/client/seen.ts +8 -6
- package/src/client/sheet.tsx +78 -4
- package/src/client/styles.css +5 -0
- package/src/client/words.ts +15 -2
- package/src/contract.mjs +110 -37
- package/src/github/api.ts +169 -0
- package/src/github/compose.ts +362 -0
- package/src/github/filer.ts +96 -0
- package/src/github/hosts.ts +117 -0
- package/src/github/index.ts +15 -0
- package/src/github/privacy.ts +131 -0
- package/src/github/relay.ts +637 -0
- package/src/server/geo.ts +32 -0
- package/src/server/handlers.ts +158 -45
- 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')
|