zigmini 0.9.0-beta.3 → 1.0.0-beta.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/README.ko.md +24 -4
- package/README.md +24 -4
- package/assets/agent/gateway-rules.md +1 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +51 -11
- package/docs/manual.md +52 -12
- package/host/omo-bridge/agent-control.ts +274 -0
- package/host/omo-bridge/bridge.ts +1 -1
- package/host/omo-bridge/continuity-control.ts +1 -1
- package/host/omo-bridge/task-control.ts +26 -16
- package/host/omo-check/patches/omo-5.1.51-compaction-long-context.patch +92 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-port.patch +2635 -0
- package/host/omo-check/patches/omo-5.1.51-compaction-recovery.patch +911 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +363 -0
- package/host/omo-check/patches/omo-5.1.51-provider-failover.patch +50 -0
- package/host/omo-check/patches/omo-5.1.51-provider-port.patch +441 -0
- package/host/omo-check/patches/omo-5.1.51-rpc-port.patch +799 -0
- package/host/omo-check/patches/omo-5.1.51-runtime-compat.patch +31 -0
- package/package.json +3 -2
- package/src/agent/api.ts +215 -0
- package/src/agent/definition.ts +177 -0
- package/src/agent/fields.ts +42 -0
- package/src/agent/files.ts +192 -0
- package/src/agent/legacy-files.ts +46 -0
- package/src/agent/list.ts +21 -0
- package/src/agent-rules.ts +14 -5
- package/src/approvals.ts +6 -1
- package/src/cli/main.ts +33 -0
- package/src/cli/messages.ts +10 -2
- package/src/cli/rotate-main.ts +2 -1
- package/src/cli/scan.ts +414 -0
- package/src/cli/security-import.ts +155 -0
- package/src/compact.ts +22 -0
- package/src/decisions.ts +6 -4
- package/src/engine/compat.json +3 -3
- package/src/engine/index.ts +1 -0
- package/src/engine/schema-guard.ts +12 -4
- package/src/engine/sdk.ts +8 -0
- package/src/formal-verification.ts +366 -0
- package/src/gc.ts +37 -6
- package/src/hub.ts +339 -79
- package/src/main.ts +95 -9
- package/src/maintenance.ts +1 -1
- package/src/manual.ts +9 -2
- package/src/profile-prompt.ts +73 -0
- package/src/profile.ts +33 -10
- package/src/protocol.ts +11 -2
- package/src/question-notifications.ts +4 -1
- package/src/reply-notifications.ts +7 -1
- package/src/repo-security-setting.ts +33 -0
- package/src/runtime/paths.ts +16 -0
- package/src/security/baseline.ts +170 -0
- package/src/security/dex-strings.ts +242 -0
- package/src/security/repo-scan.ts +369 -0
- package/src/security/revision-probe.ts +144 -0
- package/src/security/terms.ts +97 -0
- package/src/security-allow.ts +1 -0
- package/src/security-baseline.ts +113 -0
- package/src/security-check.ts +91 -16
- package/src/server-text.ts +38 -0
- package/src/store.ts +142 -16
- package/src/transcript.ts +7 -1
- package/src/ui-language.ts +33 -0
- package/src/update/check.ts +7 -3
- package/src/update/handoff-system.ts +24 -16
- package/src/update/handoff.ts +23 -12
- package/src/update/helper.ts +72 -36
- package/src/update/request.ts +46 -30
- package/src/update/state.ts +21 -11
- package/src/update/strategy.ts +12 -5
- package/src/update/target.ts +32 -15
- package/src/usage.ts +43 -20
- package/src/version.ts +39 -16
- package/src/voice/agents.ts +8 -3
- package/src/voice/install.ts +29 -18
- package/src/work-watch.ts +40 -18
- package/web/agent-icon.js +15 -9
- package/web/agents.d.ts +201 -0
- package/web/agents.js +813 -0
- package/web/app-download.js +32 -19
- package/web/app.js +1407 -278
- package/web/autocopy.js +6 -1
- package/web/boot.js +27 -8
- package/web/commands.js +20 -11
- package/web/decisions.js +70 -49
- package/web/devlog.js +25 -19
- package/web/diagram.js +8 -3
- package/web/enroll.js +43 -22
- package/web/gc.js +32 -19
- package/web/i18n/agent-icon.js +24 -0
- package/web/i18n/agents.js +48 -0
- package/web/i18n/app-download.js +26 -0
- package/web/i18n/autocopy.js +5 -0
- package/web/i18n/boot.js +24 -0
- package/web/i18n/commands.js +34 -0
- package/web/i18n/decisions.js +24 -0
- package/web/i18n/devlog.js +60 -0
- package/web/i18n/diagram.js +12 -0
- package/web/i18n/enroll.js +22 -0
- package/web/i18n/gc.js +38 -0
- package/web/i18n/lastsent.js +10 -0
- package/web/i18n/main.d.ts +4 -0
- package/web/i18n/main.js +527 -0
- package/web/i18n/manual.d.ts +4 -0
- package/web/i18n/manual.js +14 -0
- package/web/i18n/memo.js +30 -0
- package/web/i18n/notifications.js +16 -0
- package/web/i18n/profile.js +13 -0
- package/web/i18n/reference.js +26 -0
- package/web/i18n/security-inbox.js +40 -0
- package/web/i18n/slash.js +38 -0
- package/web/i18n/turn-stats.js +5 -0
- package/web/i18n/update.js +42 -0
- package/web/i18n/voice.js +28 -0
- package/web/i18n/wake.js +16 -0
- package/web/i18n/workid.js +10 -0
- package/web/i18n/workstatus.js +20 -0
- package/web/i18n.d.ts +20 -0
- package/web/i18n.js +63 -0
- package/web/index.html +156 -96
- package/web/lastsent.js +9 -4
- package/web/manual.d.ts +1 -1
- package/web/manual.js +11 -5
- package/web/memo.js +18 -13
- package/web/notifications.js +12 -7
- package/web/profile.js +42 -17
- package/web/reference.js +20 -15
- package/web/security-inbox.d.ts +18 -1
- package/web/security-inbox.js +173 -4
- package/web/slash.js +23 -13
- package/web/style.css +132 -3
- package/web/tooltip.js +5 -2
- package/web/turn-stats.js +6 -2
- package/web/update.js +31 -23
- package/web/voice.js +18 -8
- package/web/wake.d.ts +4 -0
- package/web/wake.js +7 -3
- package/web/workid.js +12 -2
- package/web/workstatus.js +36 -32
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// Private string check for everything the npm package ships (privacy audit, work 25c2b26a).
|
|
2
|
+
// Three sources, none of which holds a personal value in plain text inside this repository:
|
|
3
|
+
// 1. scripts/private-strings.hashes.json: salted SHA-256 of the owner's names, accounts, domains and device ids.
|
|
4
|
+
// A text matches when one of its word candidates (see candidates()) hashes to a listed value.
|
|
5
|
+
// 2. A plain list kept only on the release Mac (default ~/.zigmini-release/private-strings, or ZIGMINI_PRIVATE_STRINGS):
|
|
6
|
+
// one value per line, matched as a case-insensitive substring. It holds ids, tokens and addresses of that Mac.
|
|
7
|
+
// 3. Generic patterns that need no personal value: MAC addresses, NAS volume paths and phone model numbers.
|
|
8
|
+
// 4. Home folder paths (a macOS or Linux home followed by a user name). Write a home folder as ~ instead.
|
|
9
|
+
// Binary files are read as their printable strings; a zip (the APK) is read entry by entry.
|
|
10
|
+
// The same checks cover every file git tracks and every object reachable from HEAD, dev and the tags (bug 163), run by test:all.
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync, lstatSync, readFileSync } from "node:fs";
|
|
13
|
+
import { homedir, userInfo } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { inflateRawSync } from "node:zlib";
|
|
16
|
+
import { dexStrings } from "./dex-strings.ts";
|
|
17
|
+
import { findPrivate, HASH_SALT, machineTerms, normalizeTerm, PUBLIC_ACCOUNT, candidates, PATTERNS, HOME_PATH, NON_PERSONAL_HOMES } from "./terms.ts";
|
|
18
|
+
import type { PrivateHit as TermHit, PrivateSet } from "./terms.ts";
|
|
19
|
+
import { readBaseline, type BaselineAllow, type BaselineSource } from "./baseline.ts";
|
|
20
|
+
import { canonicalSecurityValue } from "../security-allow.ts";
|
|
21
|
+
import { compileRules, entropy, isAllowed, validateAllow, type Rule } from "../../scripts/secret-rules.ts";
|
|
22
|
+
import ruleConfig from "../../scripts/secret-scan.rules.json";
|
|
23
|
+
import allowConfig from "../../scripts/secret-scan.allow.json";
|
|
24
|
+
export {
|
|
25
|
+
HASH_SALT, PATTERNS, HOME_PATH, NON_PERSONAL_HOMES, homePaths, tildeHome, normalizeTerm, hashTerm,
|
|
26
|
+
candidates, PUBLIC_ACCOUNT, findPrivate, machineTerms,
|
|
27
|
+
} from "./terms.ts";
|
|
28
|
+
export type { PrivateSet } from "./terms.ts";
|
|
29
|
+
|
|
30
|
+
export const HASHES_FILE = join(process.cwd(), "scripts", "private-strings.hashes.json");
|
|
31
|
+
const RULES = compileRules(ruleConfig.rules as readonly Rule[]).filter((rule) => rule.id !== "user-home-path")
|
|
32
|
+
.map((rule) => ({ ...rule, regex: new RegExp(rule.regex.source, `${rule.regex.flags}d`) }));
|
|
33
|
+
const ALLOW = validateAllow(allowConfig.allow);
|
|
34
|
+
const GRAPHEMES = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
35
|
+
|
|
36
|
+
export interface PrivateOccurrence { readonly start: number; readonly end: number; readonly token: string }
|
|
37
|
+
export interface PrivateHit extends TermHit { readonly occurrences?: readonly PrivateOccurrence[] }
|
|
38
|
+
export interface PrivateFinding extends PrivateHit {
|
|
39
|
+
readonly file: string;
|
|
40
|
+
readonly line?: number;
|
|
41
|
+
/** Opaque archive-entry domain, independent of duplicate names and printable separators. */
|
|
42
|
+
readonly sourceId?: symbol;
|
|
43
|
+
}
|
|
44
|
+
export interface LoadedPrivateSet extends PrivateSet {
|
|
45
|
+
/** Report absent hashes separately from an empty, valid hash file. */
|
|
46
|
+
readonly hashCount: number | "absent";
|
|
47
|
+
readonly listTerms: number;
|
|
48
|
+
readonly allow: readonly BaselineAllow[];
|
|
49
|
+
readonly machine: { readonly home: string; readonly user: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Exact approved tokens own spans in one file; every occurrence of a finding must be covered. */
|
|
53
|
+
export function applyRepoAllow(findings: readonly PrivateFinding[], allow: readonly BaselineAllow[], machine: { readonly home: string; readonly user: string }) {
|
|
54
|
+
const accounts = new Set(machineTerms(machine).terms.map(normalizeTerm));
|
|
55
|
+
const values = new Set(allow.filter((entry) => entry.scopes.includes("repo") && !accounts.has(normalizeTerm(entry.value)))
|
|
56
|
+
.map((entry) => normalizeTerm(entry.value)));
|
|
57
|
+
const spans = new Map<string | symbol, PrivateOccurrence[]>();
|
|
58
|
+
for (const hit of findings) for (const occurrence of hit.occurrences ?? []) {
|
|
59
|
+
if (values.has(normalizeTerm(occurrence.token))) {
|
|
60
|
+
const allowed = spans.get(hit.sourceId ?? hit.file) ?? [];
|
|
61
|
+
allowed.push(occurrence);
|
|
62
|
+
spans.set(hit.sourceId ?? hit.file, allowed);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const kept = findings.filter((hit) => {
|
|
66
|
+
if (hit.rule === "user-home-path" || accounts.has(normalizeTerm(hit.value)) || !hit.occurrences?.length) return true;
|
|
67
|
+
const allowedSpans = spans.get(hit.sourceId ?? hit.file) ?? [];
|
|
68
|
+
return !hit.occurrences.every((occurrence) => allowedSpans.some((span) => span.start <= occurrence.start && occurrence.end <= span.end));
|
|
69
|
+
});
|
|
70
|
+
return { kept, allowedRepo: findings.length - kept.length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class RepoScanError extends Error {
|
|
74
|
+
constructor(readonly code: string, readonly exitCode: 2 | 3 = 2) { super(code); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Parse the repository-local exception boundary once; only exact object IDs are accepted. */
|
|
78
|
+
export function parseObjectExceptions(text: string): ReadonlySet<string> {
|
|
79
|
+
let input: unknown;
|
|
80
|
+
try { input = JSON.parse(text); }
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (error instanceof SyntaxError) throw new RepoScanError("private_scan_exceptions_invalid");
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
if (typeof input !== "object" || input === null || !("description" in input) || typeof input.description !== "string"
|
|
86
|
+
|| !("objects" in input) || !Array.isArray(input.objects)
|
|
87
|
+
|| !input.objects.every((oid): oid is string => typeof oid === "string" && /^[0-9a-f]{40}$/.test(oid))) {
|
|
88
|
+
throw new RepoScanError("private_scan_exceptions_invalid");
|
|
89
|
+
}
|
|
90
|
+
return new Set(input.objects);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Keep offsets through the public-account replacement and Unicode normalization. */
|
|
94
|
+
function mappedText(text: string, normalized: boolean) {
|
|
95
|
+
const starts: number[] = [], ends: number[] = [];
|
|
96
|
+
let masked = "", at = 0;
|
|
97
|
+
for (const match of text.matchAll(PUBLIC_ACCOUNT)) {
|
|
98
|
+
for (; at < match.index; at++) { masked += text[at]; starts.push(at); ends.push(at + 1); }
|
|
99
|
+
masked += " "; starts.push(match.index); ends.push(match.index + match[0].length);
|
|
100
|
+
at = match.index + match[0].length;
|
|
101
|
+
}
|
|
102
|
+
for (; at < text.length; at++) { masked += text[at]; starts.push(at); ends.push(at + 1); }
|
|
103
|
+
const mappedStarts: number[] = [], mappedEnds: number[] = [];
|
|
104
|
+
for (const { segment, index } of GRAPHEMES.segment(masked)) {
|
|
105
|
+
const transformed = (normalized ? segment.normalize("NFC") : segment).toLowerCase();
|
|
106
|
+
for (let i = 0; i < transformed.length; i++) {
|
|
107
|
+
mappedStarts.push(starts[index] ?? 0);
|
|
108
|
+
mappedEnds.push(ends[index + segment.length - 1] ?? text.length);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { text: (normalized ? masked.normalize("NFC") : masked).toLowerCase(), starts: mappedStarts, ends: mappedEnds };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function termOccurrences(text: string, hit: TermHit): PrivateOccurrence[] {
|
|
115
|
+
const mapped = mappedText(text, hit.rule === "private-term");
|
|
116
|
+
const occurrences: PrivateOccurrence[] = [];
|
|
117
|
+
const ranges = hit.rule === "private-term"
|
|
118
|
+
? [...mapped.text.matchAll(/[a-z0-9\uac00-\ud7a3._@-]+/g)].filter((match) => candidates(match[0]).has(hit.value))
|
|
119
|
+
.map((match) => ({ start: match.index, end: match.index + match[0].length }))
|
|
120
|
+
: [{ start: 0, end: mapped.text.length }];
|
|
121
|
+
const publicRanges = [...text.matchAll(PUBLIC_ACCOUNT)].map((match) => ({ start: match.index, end: match.index + match[0].length }));
|
|
122
|
+
const tokenCharacter = (at: number) => /[A-Za-z0-9\uac00-\ud7a3._%+@-]/.test(text[at] ?? "")
|
|
123
|
+
&& !publicRanges.some((range) => at >= range.start && at < range.end);
|
|
124
|
+
for (const range of ranges) {
|
|
125
|
+
let at = mapped.text.indexOf(hit.value, range.start);
|
|
126
|
+
while (hit.value.length > 0 && at >= range.start && at + hit.value.length <= range.end) {
|
|
127
|
+
const start = mapped.starts[at], end = mapped.ends[at + hit.value.length - 1];
|
|
128
|
+
if (start !== undefined && end !== undefined) {
|
|
129
|
+
let from = start, to = end;
|
|
130
|
+
while (from > 0 && tokenCharacter(from - 1)) from--;
|
|
131
|
+
while (to < text.length && tokenCharacter(to)) to++;
|
|
132
|
+
occurrences.push({ start, end, token: text.slice(from, to) });
|
|
133
|
+
}
|
|
134
|
+
at = mapped.text.indexOf(hit.value, at + 1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return occurrences;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Enrich existing detections without changing their order, values, or count. */
|
|
141
|
+
function locatedPrivate(text: string, set: PrivateSet, binary: boolean, file?: string, body = text, offset = 0): PrivateHit[] {
|
|
142
|
+
const hits = findPrivate(text, set, binary, file);
|
|
143
|
+
if (hits.length === 0) return [];
|
|
144
|
+
const positions = new Map<string, PrivateOccurrence[][]>();
|
|
145
|
+
const remember = (rule: string, value: string, occurrence: PrivateOccurrence) => {
|
|
146
|
+
const key = JSON.stringify([rule, value]);
|
|
147
|
+
const list = positions.get(key) ?? [];
|
|
148
|
+
list.push([occurrence]); positions.set(key, list);
|
|
149
|
+
};
|
|
150
|
+
for (const { id, regex } of PATTERNS) for (const match of text.matchAll(regex)) {
|
|
151
|
+
remember(id, match[0], { start: match.index, end: match.index + match[0].length, token: match[0] });
|
|
152
|
+
}
|
|
153
|
+
for (const match of text.matchAll(HOME_PATH)) if (!NON_PERSONAL_HOMES.has(match[1] ?? "")) {
|
|
154
|
+
remember("user-home-path", match[0], { start: match.index, end: match.index + match[0].length, token: match[0] });
|
|
155
|
+
}
|
|
156
|
+
let lineOffset = 0;
|
|
157
|
+
for (const line of text.split("\n")) {
|
|
158
|
+
for (const rule of RULES) for (const match of line.matchAll(rule.regex)) {
|
|
159
|
+
const value = match[rule.group ?? 0], range = match.indices?.[rule.group ?? 0];
|
|
160
|
+
if (!value || !range || (rule.minEntropy !== undefined && entropy(value) < rule.minEntropy) || isAllowed(rule.id, value, ALLOW, file)) continue;
|
|
161
|
+
const start = lineOffset + range[0], end = lineOffset + range[1];
|
|
162
|
+
const token = canonicalSecurityValue(body, rule.id, offset + start, offset + end);
|
|
163
|
+
// The canonical value is a body substring enclosing this capture, not necessarily the first equal string.
|
|
164
|
+
const tokenStart = body.lastIndexOf(token, offset + start);
|
|
165
|
+
remember(rule.id, value, { start: tokenStart - offset, end: tokenStart + token.length - offset, token });
|
|
166
|
+
}
|
|
167
|
+
lineOffset += line.length + 1;
|
|
168
|
+
}
|
|
169
|
+
return hits.map((hit) => {
|
|
170
|
+
const occurrences = hit.rule === "private-list" || hit.rule === "private-term" ? termOccurrences(text, hit)
|
|
171
|
+
: positions.get(JSON.stringify([hit.rule, hit.value]))?.shift() ?? [];
|
|
172
|
+
return { ...hit, occurrences: occurrences.map((occurrence) => ({ ...occurrence, start: occurrence.start + offset, end: occurrence.end + offset })) };
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Output limit of the git commands below (the whole repository history fits). */
|
|
177
|
+
const GIT_OUTPUT_LIMIT = 2 ** 30;
|
|
178
|
+
|
|
179
|
+
/** Printable runs (at least 4 characters) of a binary read as UTF-8 and as UTF-16LE at both byte alignments (Android resource tables use both encodings). */
|
|
180
|
+
export function binaryStrings(bytes: Uint8Array): string[] {
|
|
181
|
+
const runs: string[] = [];
|
|
182
|
+
const utf16 = (from: number) => {
|
|
183
|
+
const size = bytes.length - from;
|
|
184
|
+
return new TextDecoder("utf-16le").decode(bytes.subarray(from, from + size - (size % 2)));
|
|
185
|
+
};
|
|
186
|
+
for (const decoded of [new TextDecoder("utf-8").decode(bytes), utf16(0), utf16(1)]) {
|
|
187
|
+
for (const run of decoded.split(/[^\x20-\x7e\uac00-\ud7a3]+/)) if (run.length >= 4) runs.push(run);
|
|
188
|
+
}
|
|
189
|
+
return runs;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ZipEntry { readonly name: string; readonly data: Uint8Array }
|
|
193
|
+
|
|
194
|
+
/** Entries of a zip archive (stored or deflated), read from its central directory. */
|
|
195
|
+
export function zipEntries(bytes: Uint8Array): ZipEntry[] {
|
|
196
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
197
|
+
let end = -1;
|
|
198
|
+
for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65_557); i--) {
|
|
199
|
+
if (view.getUint32(i, true) === 0x06054b50) { end = i; break; }
|
|
200
|
+
}
|
|
201
|
+
if (end < 0) throw new Error("private_scan_zip_invalid");
|
|
202
|
+
const count = view.getUint16(end + 10, true);
|
|
203
|
+
let at = view.getUint32(end + 16, true);
|
|
204
|
+
const entries: ZipEntry[] = [];
|
|
205
|
+
for (let n = 0; n < count; n++) {
|
|
206
|
+
if (view.getUint32(at, true) !== 0x02014b50) throw new Error("private_scan_zip_invalid");
|
|
207
|
+
const method = view.getUint16(at + 10, true);
|
|
208
|
+
const size = view.getUint32(at + 20, true);
|
|
209
|
+
const nameLength = view.getUint16(at + 28, true);
|
|
210
|
+
const local = view.getUint32(at + 42, true);
|
|
211
|
+
const name = new TextDecoder().decode(bytes.subarray(at + 46, at + 46 + nameLength));
|
|
212
|
+
const start = local + 30 + view.getUint16(local + 26, true) + view.getUint16(local + 28, true);
|
|
213
|
+
const raw = bytes.subarray(start, start + size);
|
|
214
|
+
if (method !== 0 && method !== 8) throw new RepoScanError("private_scan_zip_method");
|
|
215
|
+
entries.push({ name, data: method === 0 ? raw : new Uint8Array(inflateRawSync(raw)) });
|
|
216
|
+
at += 46 + nameLength + view.getUint16(at + 30, true) + view.getUint16(at + 32, true);
|
|
217
|
+
}
|
|
218
|
+
return entries;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isBinary(bytes: Uint8Array): boolean {
|
|
222
|
+
const n = Math.min(bytes.length, 8000);
|
|
223
|
+
for (let i = 0; i < n; i++) if (bytes[i] === 0) return true;
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isZip(file: string, bytes: Uint8Array): boolean {
|
|
228
|
+
return /\.(?:apk|zip|jar|aar)$/i.test(file) && bytes[0] === 0x50 && bytes[1] === 0x4b;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Findings of one file: text line by line, a binary as its strings, a zip entry by entry (file "a.apk!classes.dex"). */
|
|
232
|
+
export function scanBytes(file: string, bytes: Uint8Array, set: PrivateSet): PrivateFinding[] {
|
|
233
|
+
if (isZip(file, bytes)) return zipEntries(bytes).flatMap((entry) => {
|
|
234
|
+
const sourceId = Symbol();
|
|
235
|
+
return scanBytes(`${file}!${entry.name}`, entry.data, set).map((hit) => ({ ...hit, sourceId: hit.sourceId ?? sourceId }));
|
|
236
|
+
});
|
|
237
|
+
const dex = dexStrings(bytes);
|
|
238
|
+
if (dex !== null) {
|
|
239
|
+
const body = dex.map(({ text }) => text).join("\n");
|
|
240
|
+
let offset = 0;
|
|
241
|
+
return dex.flatMap(({ text, sourcePackage }) => {
|
|
242
|
+
// Only the proven source-package occurrence is metadata; private registrations and every other rule still apply.
|
|
243
|
+
const found = locatedPrivate(text, set, true, file, body, offset)
|
|
244
|
+
.filter((hit) => hit.rule !== "internal-host" || hit.value !== sourcePackage).map((hit) => ({ ...hit, file }));
|
|
245
|
+
offset += text.length + 1;
|
|
246
|
+
return found;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (isBinary(bytes)) return locatedPrivate(binaryStrings(bytes).join("\n"), set, true, file).map((hit) => ({ ...hit, file }));
|
|
250
|
+
const findings: PrivateFinding[] = [];
|
|
251
|
+
const body = new TextDecoder().decode(bytes);
|
|
252
|
+
let offset = 0;
|
|
253
|
+
body.split("\n").forEach((text, index) => {
|
|
254
|
+
for (const hit of locatedPrivate(text, set, false, file, body, offset)) findings.push({ ...hit, file, line: index + 1 });
|
|
255
|
+
offset += text.length + 1;
|
|
256
|
+
});
|
|
257
|
+
return findings;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function scanPrivateFiles(root: string, files: readonly string[], set: PrivateSet): PrivateFinding[] {
|
|
261
|
+
return files.flatMap((file) => scanBytes(file, readFileSync(join(root, file)), set));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Findings in every file git tracks in a checkout (the working copy; deleted files and symbolic links are skipped). */
|
|
265
|
+
export function scanTracked(root: string, set: PrivateSet): PrivateFinding[] {
|
|
266
|
+
const files = execFileSync("git", ["-C", root, "ls-files", "-z"], { maxBuffer: GIT_OUTPUT_LIMIT }).toString().split("\0").filter((file) => file !== "");
|
|
267
|
+
return scanPrivateFiles(root, files.filter((file) => existsSync(join(root, file)) && !lstatSync(join(root, file)).isSymbolicLink()), set);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Refs whose objects the Git object check reads besides HEAD: the local dev, origin/dev and every tag. Other local and
|
|
271
|
+
* remote-tracking branches, PR refs and any other ref belong to other work and are not read (user decision "작업 브랜치 + dev"). */
|
|
272
|
+
export const SCOPE_REF = /^refs\/(?:heads\/dev|remotes\/origin\/dev|tags\/.+)$/;
|
|
273
|
+
|
|
274
|
+
/** The exact positive traversal inputs, also available to the security evidence command without reading object contents. */
|
|
275
|
+
export function gitObjectScope(gitDir: string) {
|
|
276
|
+
const git = (args: string[]) => execFileSync("git", ["--git-dir", gitDir, ...args], { maxBuffer: GIT_OUTPUT_LIMIT }).toString();
|
|
277
|
+
const included: { readonly ref: string; readonly oid: string }[] = [];
|
|
278
|
+
const excluded: { readonly ref: string; readonly oid: string }[] = [];
|
|
279
|
+
for (const line of git(["for-each-ref", "--sort=refname", "--format=%(objectname) %(refname)"]).split("\n")) {
|
|
280
|
+
if (line === "") continue;
|
|
281
|
+
const [oid, ref] = line.split(" ");
|
|
282
|
+
if (!oid || !ref) throw new Error("private_scan_git_ref_invalid");
|
|
283
|
+
const inScope = SCOPE_REF.test(ref);
|
|
284
|
+
(inScope ? included : excluded).push({ ref, oid });
|
|
285
|
+
}
|
|
286
|
+
const sourceSha = git(["rev-parse", "--revs-only", "HEAD"]).trim() || null;
|
|
287
|
+
const roots = [...new Set([...included.map(({ oid }) => oid), ...(sourceSha ? [sourceSha] : [])])];
|
|
288
|
+
return { included, excluded, sourceSha, roots };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Findings in the in-scope ref names and every blob, commit and annotated tag reachable from HEAD or an in-scope ref. A commit
|
|
292
|
+
* or tag keeps its message but excludes identity header lines. A blob is named "blob <path>" by the first path that reaches it. */
|
|
293
|
+
export function scanGitObjects(gitDir: string, set: PrivateSet, exceptions: ReadonlySet<string> = new Set()): PrivateFinding[] {
|
|
294
|
+
if ([...exceptions].some((oid) => !/^[0-9a-f]{40}$/.test(oid))) throw new RepoScanError("private_scan_exceptions_invalid");
|
|
295
|
+
const git = (args: string[], input?: string) => execFileSync("git", ["--git-dir", gitDir, ...args], { input, maxBuffer: GIT_OUTPUT_LIMIT });
|
|
296
|
+
const scope = gitObjectScope(gitDir);
|
|
297
|
+
const names = new Map<string, string>();
|
|
298
|
+
for (const line of git(["rev-list", "--objects", "--stdin"], `${scope.roots.join("\n")}\n`).toString().split("\n")) {
|
|
299
|
+
const [id, ...path] = line.split(" ");
|
|
300
|
+
if (line !== "") names.set(id!, path.join(" "));
|
|
301
|
+
}
|
|
302
|
+
const refs = scope.included.map(({ ref }) => ref);
|
|
303
|
+
const findings: PrivateFinding[] = refs.flatMap((ref) => locatedPrivate(ref, set, false).map((hit) => ({ ...hit, file: `ref ${ref}` })));
|
|
304
|
+
const ids = [...names.keys()].filter((id) => !exceptions.has(id));
|
|
305
|
+
const out = git(["cat-file", "--batch"], `${ids.join("\n")}\n`);
|
|
306
|
+
let at = 0;
|
|
307
|
+
for (const id of ids) {
|
|
308
|
+
const end = out.indexOf(10, at);
|
|
309
|
+
const [name, type, size] = out.subarray(at, end).toString().split(" ");
|
|
310
|
+
if (name !== id) throw new Error(`private_scan_git_batch:${id}`);
|
|
311
|
+
const body = out.subarray(end + 1, end + 1 + Number(size));
|
|
312
|
+
at = end + 1 + Number(size) + 1;
|
|
313
|
+
if (type === "tree") continue;
|
|
314
|
+
const label = type === "blob" ? `blob ${names.get(id)} ${id}` : `${type} ${id}`;
|
|
315
|
+
const content = type === "commit" || type === "tag" ? Buffer.from(body.toString().split("\n").map((line, index, lines) =>
|
|
316
|
+
index < lines.indexOf("") && /^(?:author|committer|tagger) /.test(line) ? " ".repeat(line.length) : line).join("\n")) : body;
|
|
317
|
+
const file = names.get(id) || label;
|
|
318
|
+
findings.push(...scanBytes(file, content, set).map((hit) => ({ ...hit, file: `${label}${hit.file.slice(file.length)}` })));
|
|
319
|
+
}
|
|
320
|
+
return findings;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Lines of a list file, trimmed and lower-cased; blank lines and # comments are skipped. */
|
|
324
|
+
export function parseList(text: string): string[] {
|
|
325
|
+
return text.split("\n").map((line) => line.trim().toLowerCase()).filter((line) => line !== "" && !line.startsWith("#"));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function loadPrivateSet(source: BaselineSource, hashesFile = HASHES_FILE, machine = { home: homedir(), user: userInfo().username }): LoadedPrivateSet {
|
|
329
|
+
let terms: readonly string[];
|
|
330
|
+
let allow: readonly BaselineAllow[] = [];
|
|
331
|
+
switch (source.kind) {
|
|
332
|
+
case "baseline": {
|
|
333
|
+
const result = readBaseline(source);
|
|
334
|
+
if (!result.ok) throw new RepoScanError(`private_scan_baseline_${result.error}`, result.error === "missing" || result.error === "empty" ? 3 : 2);
|
|
335
|
+
terms = result.file.terms;
|
|
336
|
+
allow = result.file.allow;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
case "legacy-list":
|
|
340
|
+
try { terms = parseList(readFileSync(source.path, "utf8")); }
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (error instanceof Error && "code" in error) throw new RepoScanError(error.code === "ENOENT" ? "private_scan_baseline_missing" : "private_scan_baseline_unreadable", error.code === "ENOENT" ? 3 : 2);
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
if (terms.length === 0) throw new RepoScanError("private_scan_baseline_empty", 3);
|
|
346
|
+
break;
|
|
347
|
+
case "hash-only": terms = []; break;
|
|
348
|
+
case "missing": throw new RepoScanError("private_scan_baseline_missing", 3);
|
|
349
|
+
default: {
|
|
350
|
+
const unreachable: never = source;
|
|
351
|
+
throw new RepoScanError(`private_scan_source_invalid:${unreachable}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
let hashes: ReadonlySet<string> = new Set(), hashCount: number | "absent" = "absent";
|
|
355
|
+
try {
|
|
356
|
+
const parsed: unknown = JSON.parse(readFileSync(hashesFile, "utf8"));
|
|
357
|
+
if (typeof parsed !== "object" || parsed === null || !("salt" in parsed) || parsed.salt !== HASH_SALT
|
|
358
|
+
|| !("hashes" in parsed) || !Array.isArray(parsed.hashes)
|
|
359
|
+
|| !parsed.hashes.every((hash): hash is string => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash))) {
|
|
360
|
+
throw new RepoScanError("private_scan_hashes_invalid");
|
|
361
|
+
}
|
|
362
|
+
hashes = new Set(parsed.hashes);
|
|
363
|
+
hashCount = hashes.size;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw new RepoScanError("private_scan_hashes_invalid");
|
|
366
|
+
}
|
|
367
|
+
const automatic = machineTerms(machine).terms.filter((term) => !terms.includes(term));
|
|
368
|
+
return { hashes, terms: [...terms, ...automatic], hashCount, listTerms: terms.length, allow, machine: { ...machine } };
|
|
369
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { request, type IncomingMessage } from "node:http";
|
|
2
|
+
import type { ScanFreshness } from "../cli/scan.ts";
|
|
3
|
+
import { runtimePaths, type Env } from "../runtime/paths.ts";
|
|
4
|
+
import type { BaselineFile } from "./baseline.ts";
|
|
5
|
+
|
|
6
|
+
type Revision = Pick<BaselineFile, "baselineId" | "revision">;
|
|
7
|
+
export interface RevisionClock {
|
|
8
|
+
readonly now: () => number;
|
|
9
|
+
/** Schedule asynchronously and return cancellation for that alarm. */
|
|
10
|
+
readonly schedule: (callback: () => void, delayMs: number) => () => void;
|
|
11
|
+
}
|
|
12
|
+
export interface RevisionEvents {
|
|
13
|
+
readonly connect: () => void;
|
|
14
|
+
readonly response: (status: number | undefined, contentType: string | undefined) => void;
|
|
15
|
+
readonly data: (chunk: Buffer) => void;
|
|
16
|
+
readonly end: () => void;
|
|
17
|
+
readonly error: (code: string | undefined) => void;
|
|
18
|
+
}
|
|
19
|
+
export interface RevisionConnection {
|
|
20
|
+
readonly start: () => void;
|
|
21
|
+
readonly destroy: () => void;
|
|
22
|
+
}
|
|
23
|
+
/** Construct first; callbacks may run only after start(), as with node:http. */
|
|
24
|
+
export type RevisionTransport = (port: number, events: RevisionEvents) => RevisionConnection;
|
|
25
|
+
export interface RevisionProbeDeps {
|
|
26
|
+
readonly env?: Env;
|
|
27
|
+
readonly clock?: RevisionClock;
|
|
28
|
+
readonly transport?: RevisionTransport;
|
|
29
|
+
}
|
|
30
|
+
const CLOCK: RevisionClock = {
|
|
31
|
+
now: () => performance.now(),
|
|
32
|
+
schedule: (callback, delay) => {
|
|
33
|
+
const timer = setTimeout(callback, delay);
|
|
34
|
+
return () => clearTimeout(timer);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** One fresh connection, no authentication, proxy, redirect or retry machinery. */
|
|
39
|
+
export function nodeRevisionTransport(port: number, events: RevisionEvents): RevisionConnection {
|
|
40
|
+
let response: IncomingMessage | undefined;
|
|
41
|
+
const req = request({ hostname: "127.0.0.1", port, method: "GET", agent: false,
|
|
42
|
+
path: "/api/settings/security-check/baseline/revision", headers: { accept: "application/json" } });
|
|
43
|
+
req.once("socket", (socket) => { socket.once("connect", events.connect); });
|
|
44
|
+
req.once("response", (message) => {
|
|
45
|
+
response = message;
|
|
46
|
+
message.on("data", (chunk: Buffer) => events.data(chunk));
|
|
47
|
+
message.once("end", events.end);
|
|
48
|
+
message.once("error", () => events.error(undefined));
|
|
49
|
+
message.once("aborted", () => events.error(undefined));
|
|
50
|
+
events.response(message.statusCode, message.headers["content-type"]);
|
|
51
|
+
});
|
|
52
|
+
req.once("error", (error: NodeJS.ErrnoException) => events.error(error.code));
|
|
53
|
+
req.once("close", () => events.error(undefined));
|
|
54
|
+
req.once("upgrade", (_message, socket) => { socket.destroy(); events.error(undefined); });
|
|
55
|
+
return { start: () => { req.end(); }, destroy: () => { response?.destroy(); req.destroy(); } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Parse only the two revision fields. Identity mismatch takes precedence over revision ordering. */
|
|
59
|
+
export function classifyRevision(file: Revision | null, input: unknown): ScanFreshness {
|
|
60
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)
|
|
61
|
+
|| !("baselineId" in input) || typeof input.baselineId !== "string" || input.baselineId.trim().length === 0
|
|
62
|
+
|| !("revision" in input) || typeof input.revision !== "number"
|
|
63
|
+
|| !Number.isSafeInteger(input.revision) || input.revision < 0) return "probe_failed";
|
|
64
|
+
if (file === null) return "legacy_unverifiable";
|
|
65
|
+
if (input.baselineId !== file.baselineId) return "mismatch";
|
|
66
|
+
if (input.revision > file.revision) return "stale";
|
|
67
|
+
if (input.revision < file.revision) return "mismatch";
|
|
68
|
+
return "verified";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The one-second monotonic budget includes connection, headers, body, decoding and validation. */
|
|
72
|
+
export function probeRevision(baseline: { readonly file: Revision | null }, deps: RevisionProbeDeps = {}): Promise<ScanFreshness> {
|
|
73
|
+
const clock = deps.clock ?? CLOCK;
|
|
74
|
+
const started = clock.now();
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
let connected = false, settled = false, bytes = 0;
|
|
77
|
+
let connection: RevisionConnection | undefined;
|
|
78
|
+
let cancel: (() => void) | undefined;
|
|
79
|
+
const chunks: Buffer[] = [];
|
|
80
|
+
const finish = (freshness: ScanFreshness) => {
|
|
81
|
+
if (settled) return;
|
|
82
|
+
settled = true;
|
|
83
|
+
cancel?.();
|
|
84
|
+
connection?.destroy();
|
|
85
|
+
chunks.length = 0;
|
|
86
|
+
resolve(freshness);
|
|
87
|
+
};
|
|
88
|
+
const expired = () => finish(connected ? "probe_failed" : "unchecked_offline");
|
|
89
|
+
const active = () => {
|
|
90
|
+
if (settled) return false;
|
|
91
|
+
if (clock.now() - started < 1000) return true;
|
|
92
|
+
expired();
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
const alarm = () => {
|
|
96
|
+
if (settled) return;
|
|
97
|
+
const remaining = 1000 - (clock.now() - started);
|
|
98
|
+
if (remaining > 0) cancel = clock.schedule(alarm, remaining);
|
|
99
|
+
else expired();
|
|
100
|
+
};
|
|
101
|
+
const events: RevisionEvents = {
|
|
102
|
+
connect: () => { if (active()) connected = true; },
|
|
103
|
+
response: (status, contentType) => {
|
|
104
|
+
if (!active()) return;
|
|
105
|
+
if (status !== 200 || !/^application\/json(?:\s*;|$)/i.test(contentType ?? "")) finish("probe_failed");
|
|
106
|
+
},
|
|
107
|
+
data: (chunk) => {
|
|
108
|
+
if (!active()) return;
|
|
109
|
+
bytes += chunk.byteLength;
|
|
110
|
+
if (bytes > 4096) { finish("probe_failed"); return; }
|
|
111
|
+
chunks.push(chunk);
|
|
112
|
+
},
|
|
113
|
+
end: () => {
|
|
114
|
+
if (!active()) return;
|
|
115
|
+
let freshness: ScanFreshness;
|
|
116
|
+
try {
|
|
117
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks, bytes));
|
|
118
|
+
const input: unknown = JSON.parse(text);
|
|
119
|
+
freshness = classifyRevision(baseline.file, input);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!(error instanceof SyntaxError) && !(error instanceof TypeError)) throw error;
|
|
122
|
+
freshness = "probe_failed";
|
|
123
|
+
}
|
|
124
|
+
if (active()) finish(freshness);
|
|
125
|
+
},
|
|
126
|
+
error: (code) => {
|
|
127
|
+
if (!active()) return;
|
|
128
|
+
const offline = !connected && (code === "ECONNREFUSED" || code === "EHOSTUNREACH" || code === "ENETUNREACH");
|
|
129
|
+
finish(offline ? "unchecked_offline" : "probe_failed");
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
const port = runtimePaths(deps.env ?? process.env).ports.web;
|
|
134
|
+
alarm();
|
|
135
|
+
if (!active()) return;
|
|
136
|
+
connection = (deps.transport ?? nodeRevisionTransport)(port, events);
|
|
137
|
+
if (!active()) return;
|
|
138
|
+
connection.start();
|
|
139
|
+
} catch (error) {
|
|
140
|
+
// Request construction is an I/O boundary; never expose private error text.
|
|
141
|
+
events.error(error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { compileRules, scanLine, validateAllow } from "../../scripts/secret-rules.ts";
|
|
4
|
+
import ruleConfig from "../../scripts/secret-scan.rules.json";
|
|
5
|
+
import allowConfig from "../../scripts/secret-scan.allow.json";
|
|
6
|
+
import type { Rule } from "../../scripts/secret-rules.ts";
|
|
7
|
+
|
|
8
|
+
const EXPOSURE_RULES = compileRules(ruleConfig.rules as readonly Rule[]).filter((rule) => rule.id !== "user-home-path");
|
|
9
|
+
const EXPOSURE_ALLOW = validateAllow(allowConfig.allow);
|
|
10
|
+
|
|
11
|
+
export const HASH_SALT = "zigmini-private-strings-v1:";
|
|
12
|
+
|
|
13
|
+
export interface PrivateSet {
|
|
14
|
+
readonly hashes: ReadonlySet<string>;
|
|
15
|
+
/** Lower-case plain values from the release Mac's list. */
|
|
16
|
+
readonly terms: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
export interface PrivateHit { readonly rule: string; readonly value: string }
|
|
19
|
+
|
|
20
|
+
export const PATTERNS: readonly { readonly id: string; readonly regex: RegExp }[] = [
|
|
21
|
+
// A run of exactly six colon-separated byte pairs; longer runs (certificate fingerprints) do not match.
|
|
22
|
+
{ id: "mac-address", regex: /(?<![0-9A-Fa-f:])(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}(?![0-9A-Fa-f:])/g },
|
|
23
|
+
{ id: "nas-volume-path", regex: /\/volume\d+\//g },
|
|
24
|
+
{ id: "phone-model", regex: /\bSM-[A-Z]\d{3}[A-Z]{0,2}\b/g },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** A home folder path where a path starts (line start, space, quote, bracket, = , : | < > or file://), not a "home" folder
|
|
28
|
+
* inside another path such as ${dir}/home/x; group 1 is the user name. */
|
|
29
|
+
export const HOME_PATH = /(?<=^|[\s"'`(\[{<>=,:|]|file:\/\/)\/(?:Users|home)\/([A-Za-z0-9_][A-Za-z0-9._-]*)/g;
|
|
30
|
+
/** A loopback address names no person or machine, so a list value that is one is ignored (ws://127.0.0.1:18990 is the default bridge). */
|
|
31
|
+
const LOOPBACK = /^(?:(?:wss?|https?):\/\/)?(?:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost|\[::1\]|::1)(?::\d+)?\/?$/;
|
|
32
|
+
/** User names that are not a person: the shared macOS folder, the Docker image user, the Emscripten virtual user of the
|
|
33
|
+
* vendored ONNX runtime and placeholder names of tests. */
|
|
34
|
+
export const NON_PERSONAL_HOMES: ReadonlySet<string> = new Set(["Shared", "bun", "web_user", "x", "me", "example", "someone", "somebody", "qa"]);
|
|
35
|
+
|
|
36
|
+
/** Home folder paths of a text whose user name is not in NON_PERSONAL_HOMES. */
|
|
37
|
+
export function homePaths(text: string): string[] {
|
|
38
|
+
return [...text.matchAll(HOME_PATH)].filter((match) => !NON_PERSONAL_HOMES.has(match[1]!)).map((match) => match[0]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A path for a committed file: the home folder written as ~. */
|
|
42
|
+
export function tildeHome(text: string, home = homedir()): string {
|
|
43
|
+
return home.length > 1 ? text.split(home).join("~") : text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeTerm(value: string): string {
|
|
47
|
+
return value.normalize("NFC").toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hashTerm(term: string): string {
|
|
51
|
+
return createHash("sha256").update(HASH_SALT + normalizeTerm(term)).digest("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Automatic account terms; the home is only used to redact report paths, never as a substring term. */
|
|
55
|
+
export function machineTerms({ home, user }: { readonly home: string; readonly user: string }): { readonly terms: readonly string[]; readonly home: string } {
|
|
56
|
+
const name = user.toLowerCase();
|
|
57
|
+
return { terms: name.length < 3 ? [] : [name], home };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every piece of a text that a hashed value can equal: words, their @ . _ - parts, dotted suffixes and 2-4 syllable Hangul runs. */
|
|
61
|
+
export function candidates(text: string): Set<string> {
|
|
62
|
+
const out = new Set<string>();
|
|
63
|
+
for (const token of text.normalize("NFC").toLowerCase().split(/[^a-z0-9\uac00-\ud7a3._@-]+/)) {
|
|
64
|
+
const word = token.replace(/^[._@-]+|[._@-]+$/g, "");
|
|
65
|
+
if (word.length < 2) continue;
|
|
66
|
+
out.add(word);
|
|
67
|
+
for (const part of word.split("@")) {
|
|
68
|
+
out.add(part);
|
|
69
|
+
const labels = part.split(".");
|
|
70
|
+
for (let i = 1; i < labels.length - 1; i++) out.add(labels.slice(i).join("."));
|
|
71
|
+
for (const piece of part.split(/[._-]/)) if (piece.length >= 2) out.add(piece);
|
|
72
|
+
}
|
|
73
|
+
for (const run of word.match(/[\uac00-\ud7a3]+/g) ?? []) {
|
|
74
|
+
for (let size = 2; size <= Math.min(4, run.length); size++) {
|
|
75
|
+
for (let i = 0; i + size <= run.length; i++) out.add(run.slice(i, i + size));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The public GitHub account of the repository: its name (also in github.com/owen-ever/...) and its noreply commit address.
|
|
83
|
+
* They are public, so they are removed before the private value checks. The name directly before an @ of another domain
|
|
84
|
+
* is kept, because that is a personal mail address. */
|
|
85
|
+
export const PUBLIC_ACCOUNT = new RegExp(String.raw`(?<![A-Za-z0-9._%+-])(?:215776236\+${"owen-ever"}@users\.noreply\.github\.com(?![A-Za-z0-9.-])|owen-ever(?![A-Za-z0-9_@-]|\.[A-Za-z0-9-]))`, "gi");
|
|
86
|
+
|
|
87
|
+
export function findPrivate(text: string, set: PrivateSet, binary = false, file?: string): PrivateHit[] {
|
|
88
|
+
const hits: PrivateHit[] = [];
|
|
89
|
+
const masked = text.replace(PUBLIC_ACCOUNT, " ");
|
|
90
|
+
for (const candidate of candidates(masked)) if (set.hashes.has(hashTerm(candidate))) hits.push({ rule: "private-term", value: candidate });
|
|
91
|
+
const lower = masked.toLowerCase();
|
|
92
|
+
for (const term of set.terms) if (lower.includes(term) && !LOOPBACK.test(term)) hits.push({ rule: "private-list", value: term });
|
|
93
|
+
if (!binary) for (const { id, regex } of PATTERNS) for (const match of text.matchAll(regex)) hits.push({ rule: id, value: match[0] });
|
|
94
|
+
for (const path of homePaths(text)) hits.push({ rule: "user-home-path", value: path });
|
|
95
|
+
for (const line of text.split("\n")) for (const { rule, value } of scanLine(line, EXPOSURE_RULES, EXPOSURE_ALLOW, file)) hits.push({ rule, value });
|
|
96
|
+
return hits;
|
|
97
|
+
}
|
package/src/security-allow.ts
CHANGED