@titan-design/egress-scan 0.2.0 → 0.5.0

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.md CHANGED
@@ -15,6 +15,7 @@ bin does that work for a pre-push hook and a CI job. Tracked by TP-405.
15
15
  titan-egress-scan pre-push <remote> # the commits a push sends; reads git's pre-push stdin
16
16
  titan-egress-scan range <base> <head> # every commit in base..head, for CI
17
17
  titan-egress-scan tree # every tracked file at HEAD, once per repo at rollout
18
+ titan-egress-scan text [--file <path>] # free text from stdin or a file: PR title, body, branch name
18
19
  titan-egress-scan install-hook # write the pre-push hook
19
20
  ```
20
21
 
@@ -27,12 +28,25 @@ notices and errors to stderr.
27
28
  in turn (`--diff-merges=separate`), because git's combined diff ignores `--text`; a path
28
29
  both diffs name is reported once. The message is read with `--encoding=UTF-8`, so
29
30
  `i18n.logOutputEncoding` cannot re-encode it past the rules.
31
+ - **Text.** `text` scans free text (a PR title, body or branch name) with the generic rules and
32
+ the private term list, for the CC-269 and CC-270 callers. It reads stdin, or `--file <path>`
33
+ (never both); an unreadable file exits 2. A finding is `<line>:<col> <rule>[ #<term>]`, never
34
+ the matched text; `\r\n` counts as one line break, and a column counts UTF-16 units from 1.
35
+ Input over the 128 MiB limit exits 2. It needs no git repo and applies no allow file, since
36
+ prose has no path to allow. Exit codes match `range` and `pre-push`.
37
+ - **Idents and ref names.** Each scanned commit's raw author and committer name and email are
38
+ scanned too, and a finding names the field (`commit <sha> author.name`, `author.email`,
39
+ `committer.name`, `committer.email`). `pre-push` also scans the local and remote ref names on
40
+ each stdin line, so a branch or tag named after a private term is refused as
41
+ `push line <n> local ref` or `push line <n> remote ref`. A ref deletion is not scanned, so a
42
+ leaked ref can still be deleted. Idents and ref names are never allowable.
30
43
  - **Binary files are scanned as text.** Every `git show` and `git diff` passes `--text`, so a
31
44
  file git calls binary (one NUL byte is enough) still yields its lines, and the report's
32
45
  `binary files skipped` stays 0; if git ever prints a file as binary anyway, the scan exits 2.
33
46
  There is no opt-out, since an opt-out would be a bypass.
34
47
  Matching is on the bytes decoded as UTF-8: a term written in UTF-16 or another encoding is
35
- not matched.
48
+ not matched. **UTF-16 text is not scanned**: a UTF-16LE or UTF-16BE file passes even
49
+ with `--text`, because its NUL-interleaved bytes never decode to the term.
36
50
  - **Size limit.** A commit whose patch text is over 128 MiB (`MAX_PATCH_BYTES`) exits 2 with
37
51
  one line naming its short sha and the limit. `tree` has the same limit on the whole tree's
38
52
  patch text and exits 2 with one line naming `tree` and the limit. GitHub itself refuses a file over 100 MiB.
@@ -99,6 +113,9 @@ only the range endpoints: a leak added and then removed is still in the pushed h
99
113
 
100
114
  `.egress-allow` holds `<glob> <rule-id> <reason>` lines. The reason must name a task id
101
115
  such as `TP-405`, and the glob must name at least one literal path segment.
116
+ Braces expand (`docs/{a,b}.md`), and every alternative must name a literal segment. A glob
117
+ that expands past 256 alternatives is malformed. A backslash is not an escape; it matches a
118
+ literal backslash.
102
119
  `private-term` is never allowable. `parseAllow` throws `AllowFileError`
103
120
  on any malformed line; a caller must fail the scan on it, never fall back to an empty list.
104
121
 
package/dist/bin.js CHANGED
@@ -7,8 +7,10 @@ import {
7
7
  parseAllow,
8
8
  parseDiff,
9
9
  parseTerms,
10
- scan
11
- } from "./chunk-RK4WHDXR.js";
10
+ scan,
11
+ scanText,
12
+ zeroCounts
13
+ } from "./chunk-GJWQ3DYH.js";
12
14
 
13
15
  // src/bin.ts
14
16
  import * as fs3 from "fs";
@@ -130,7 +132,8 @@ var PATCH_FLAGS = [
130
132
  "--src-prefix=a/",
131
133
  "--dst-prefix=b/"
132
134
  ];
133
- var MESSAGE_FLAGS = ["--no-patch", "--encoding=UTF-8", "--format=%B"];
135
+ var MESSAGE_FLAGS = ["--no-patch", "--encoding=UTF-8", "--format=%an%x00%ae%x00%cn%x00%ce%x00%B"];
136
+ var IDENT_FIELDS = ["author.name", "author.email", "committer.name", "committer.email"];
134
137
  var COMMIT_PATCH_FLAGS = ["--diff-merges=separate", "--format=", ...PATCH_FLAGS];
135
138
  var MAX_BUFFER = 1024 * 1024 * 1024;
136
139
  var END_OF_OPTIONS = "--end-of-options";
@@ -181,9 +184,18 @@ function parsePrePush(stdin) {
181
184
  throw new ConfigError(`pre-push stdin line ${i + 1}: field ${field + 1} is not a full sha`);
182
185
  }
183
186
  }
184
- return { localSha: fields[1] ?? "", remoteSha: fields[3] ?? "" };
187
+ return { localRef: fields[0] ?? "", localSha: fields[1] ?? "", remoteRef: fields[2] ?? "", remoteSha: fields[3] ?? "" };
185
188
  });
186
189
  }
190
+ function refSource(pushLines) {
191
+ const idents = pushLines.flatMap(
192
+ (line, i) => isZeroSha(line.localSha) ? [] : [
193
+ { field: `push line ${i + 1} local ref`, text: line.localRef },
194
+ { field: `push line ${i + 1} remote ref`, text: line.remoteRef }
195
+ ]
196
+ );
197
+ return { files: [], binaryFiles: 0, idents };
198
+ }
187
199
  function hasCommit(cwd, sha) {
188
200
  return spawnSync("git", ["cat-file", "-e", END_OF_OPTIONS, `${sha}^{commit}`], { cwd }).status === 0;
189
201
  }
@@ -213,11 +225,17 @@ function showCommit(cwd, sha, flags, maxBytes) {
213
225
  if (isOverBuffer(result.error)) throw new PatchTooLargeError(sha, maxBytes);
214
226
  return stdoutOf(result, "show");
215
227
  }
228
+ function splitHeader(text) {
229
+ const parts = text.split("\0");
230
+ const idents = IDENT_FIELDS.map((field, i) => ({ field, text: parts[i] ?? "" }));
231
+ const message = parts.slice(IDENT_FIELDS.length).join("\0").replace(/\n+$/, "").split("\n");
232
+ return { idents, message };
233
+ }
216
234
  function readCommit(cwd, sha, maxPatchBytes = MAX_PATCH_BYTES) {
217
235
  requireRevision(sha, "commit");
218
- const message = showCommit(cwd, sha, MESSAGE_FLAGS, maxPatchBytes).replace(/\n+$/, "").split("\n");
236
+ const { idents, message } = splitHeader(showCommit(cwd, sha, MESSAGE_FLAGS, maxPatchBytes));
219
237
  const patch = parseDiff(showCommit(cwd, sha, COMMIT_PATCH_FLAGS, maxPatchBytes));
220
- return requireAllText({ ...patch, sha, message }, `commit ${sha.slice(0, 7)}`);
238
+ return requireAllText({ ...patch, sha, message, idents }, `commit ${sha.slice(0, 7)}`);
221
239
  }
222
240
  function readTree(cwd, maxPatchBytes = MAX_PATCH_BYTES) {
223
241
  const emptyTree = git(cwd, ["hash-object", "-t", "tree", "--stdin"], "").trim();
@@ -256,6 +274,7 @@ var USAGE = [
256
274
  " pre-push <remote> scan the commits a push sends (reads git's pre-push stdin)",
257
275
  " range <base> <head> scan every commit in base..head (CI)",
258
276
  " tree scan every tracked file at HEAD",
277
+ " text [--file <path>] scan free text from stdin or a file, reporting line:col and rule id",
259
278
  " install-hook install the pre-push hook into git's hooks directory",
260
279
  "files git calls binary are scanned as text; a commit or tree over 128 MiB of patch text exits 2",
261
280
  "exit: 0 clean, 1 findings, 2 usage or configuration error"
@@ -265,12 +284,9 @@ function readCommits(root, shas, maxPatchBytes) {
265
284
  return [...new Set(shas)].map((sha) => readCommit(root, sha, maxPatchBytes));
266
285
  }
267
286
  function prePushSources(root, remote, io) {
268
- const updates = parsePrePush(io.readStdin());
269
- return readCommits(
270
- root,
271
- updates.flatMap((update) => commitsForUpdate(root, remote, update)),
272
- io.maxPatchBytes
273
- );
287
+ const pushLines = parsePrePush(io.readStdin());
288
+ const shas = pushLines.flatMap((line) => commitsForUpdate(root, remote, line));
289
+ return [refSource(pushLines), ...readCommits(root, shas, io.maxPatchBytes)];
274
290
  }
275
291
  function runScan(io, collect) {
276
292
  const root = repoRoot(io.cwd);
@@ -281,6 +297,27 @@ function runScan(io, collect) {
281
297
  for (const line of formatReport(result.findings, { ...result, termsLoaded: loaded })) io.out(line);
282
298
  return result.findings.length > 0 ? 1 : 0;
283
299
  }
300
+ function readTextInput(file, io) {
301
+ if (file === void 0) return io.readStdin();
302
+ try {
303
+ return io.readFile(file);
304
+ } catch {
305
+ throw new ConfigError("text: --file could not be read");
306
+ }
307
+ }
308
+ function runText(file, io) {
309
+ const text = readTextInput(file, io);
310
+ const limit = io.maxPatchBytes ?? MAX_PATCH_BYTES;
311
+ if (Buffer.byteLength(text) > limit) {
312
+ throw new ConfigError(`text: input is over the scan limit of ${formatBytes(limit)}; refusing it`);
313
+ }
314
+ const { terms, loaded, notices } = loadTerms(io.env);
315
+ for (const notice of notices) io.err(PREFIX + notice);
316
+ const findings = scanText(text, { terms });
317
+ const summary = { allowed: zeroCounts(), binaryFilesSkipped: 0, termsLoaded: loaded };
318
+ for (const line of formatReport(findings, summary)) io.out(line);
319
+ return findings.length > 0 ? 1 : 0;
320
+ }
284
321
  var INSTALL_MESSAGES = {
285
322
  installed: "pre-push hook installed",
286
323
  updated: "pre-push hook updated",
@@ -307,7 +344,8 @@ function expectValid(command, args, valid, what) {
307
344
  if (!valid(value)) throw new ConfigError(`${command}: argument ${i + 1} is not ${what}`);
308
345
  });
309
346
  }
310
- function dispatch(command, args, io) {
347
+ function dispatch(command, args, file, io) {
348
+ if (file !== void 0 && command !== "text") throw new ConfigError("--file is only valid for text");
311
349
  switch (command) {
312
350
  case "pre-push":
313
351
  expectArgs(command, args, 1, 2);
@@ -323,6 +361,9 @@ function dispatch(command, args, io) {
323
361
  case "tree":
324
362
  expectArgs(command, args, 0, 0);
325
363
  return runScan(io, (root) => [readTree(root, io.maxPatchBytes)]);
364
+ case "text":
365
+ expectArgs(command, args, 0, 0);
366
+ return runText(file, io);
326
367
  case "install-hook":
327
368
  expectArgs(command, args, 0, 0);
328
369
  return runInstall(io);
@@ -330,28 +371,38 @@ function dispatch(command, args, io) {
330
371
  throw new ConfigError(command === void 0 ? "missing command" : "unknown command");
331
372
  }
332
373
  }
374
+ function parseOptions(argv) {
375
+ try {
376
+ return parseArgs({
377
+ args: [...argv],
378
+ allowPositionals: true,
379
+ tokens: true,
380
+ options: { help: { type: "boolean", short: "h" }, file: { type: "string" } }
381
+ });
382
+ } catch {
383
+ throw new ConfigError("unknown option or missing option value (not shown)");
384
+ }
385
+ }
333
386
  function parseCommandLine(argv) {
334
- const { positionals, tokens } = parseArgs({
335
- args: [...argv],
336
- allowPositionals: true,
337
- tokens: true,
338
- options: { help: { type: "boolean", short: "h" } }
339
- });
387
+ const { positionals, tokens, values } = parseOptions(argv);
340
388
  const command = tokens.find((token) => token.kind === "positional");
341
389
  const helps = tokens.filter((token) => token.kind === "option" && token.name === "help");
342
390
  if (helps.some((token) => command !== void 0 && token.index > command.index)) {
343
391
  throw new ConfigError("a help flag after the command is not allowed");
344
392
  }
345
- return { positionals, help: helps.length > 0 };
393
+ if (tokens.filter((token) => token.kind === "option" && token.name === "file").length > 1) {
394
+ throw new ConfigError("--file may be given only once");
395
+ }
396
+ return { positionals, help: helps.length > 0, file: values.file };
346
397
  }
347
398
  function runCli(argv, io) {
348
399
  try {
349
- const { positionals, help } = parseCommandLine(argv);
400
+ const { positionals, help, file } = parseCommandLine(argv);
350
401
  if (help) {
351
402
  for (const line of USAGE) io.out(line);
352
403
  return 0;
353
404
  }
354
- return dispatch(positionals[0], positionals.slice(1), io);
405
+ return dispatch(positionals[0], positionals.slice(1), file, io);
355
406
  } catch (error) {
356
407
  io.err(PREFIX + (error instanceof Error ? error.message : "unexpected error"));
357
408
  if (error instanceof ConfigError) io.err(USAGE[0] ?? "");
@@ -364,6 +415,7 @@ process.exitCode = runCli(process.argv.slice(2), {
364
415
  cwd: process.cwd(),
365
416
  env: process.env,
366
417
  readStdin: () => fs3.readFileSync(0, "utf-8"),
418
+ readFile: (file) => fs3.readFileSync(file, "utf-8"),
367
419
  out: (line) => process.stdout.write(`${line}
368
420
  `),
369
421
  err: (line) => process.stderr.write(`${line}
package/dist/bin.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/bin.ts","../src/cli.ts","../src/config.ts","../src/git.ts","../src/install.ts"],"sourcesContent":["#!/usr/bin/env node\nimport * as fs from \"node:fs\";\nimport { runCli } from \"./cli.js\";\n\nprocess.exitCode = runCli(process.argv.slice(2), {\n cwd: process.cwd(),\n env: process.env,\n readStdin: () => fs.readFileSync(0, \"utf-8\"),\n out: (line) => process.stdout.write(`${line}\\n`),\n err: (line) => process.stderr.write(`${line}\\n`),\n});\n","import { parseArgs } from \"node:util\";\nimport { ConfigError, loadAllow, loadTerms } from \"./config.js\";\nimport type { ScanSource } from \"./diff.js\";\nimport {\n commitsForRange,\n commitsForUpdate,\n isRemoteName,\n isRevision,\n parsePrePush,\n readCommit,\n readTree,\n repoRoot,\n} from \"./git.js\";\nimport { installHook, type InstallResult } from \"./install.js\";\nimport { formatReport } from \"./report.js\";\nimport { scan } from \"./scan.js\";\n\nexport interface CliIo {\n readonly cwd: string;\n readonly env: Readonly<Record<string, string | undefined>>;\n readStdin(): string;\n out(line: string): void;\n err(line: string): void;\n /** Overrides `MAX_PATCH_BYTES` for tests; the bin never sets it. */\n readonly maxPatchBytes?: number;\n}\n\nconst USAGE = [\n \"usage: titan-egress-scan <command>\",\n \" pre-push <remote> scan the commits a push sends (reads git's pre-push stdin)\",\n \" range <base> <head> scan every commit in base..head (CI)\",\n \" tree scan every tracked file at HEAD\",\n \" install-hook install the pre-push hook into git's hooks directory\",\n \"files git calls binary are scanned as text; a commit or tree over 128 MiB of patch text exits 2\",\n \"exit: 0 clean, 1 findings, 2 usage or configuration error\",\n];\n\nconst PREFIX = \"titan-egress-scan: \";\n\nfunction readCommits(root: string, shas: readonly string[], maxPatchBytes?: number): ScanSource[] {\n return [...new Set(shas)].map((sha) => readCommit(root, sha, maxPatchBytes));\n}\n\nfunction prePushSources(root: string, remote: string, io: CliIo): ScanSource[] {\n const updates = parsePrePush(io.readStdin());\n return readCommits(\n root,\n updates.flatMap((update) => commitsForUpdate(root, remote, update)),\n io.maxPatchBytes,\n );\n}\n\nfunction runScan(io: CliIo, collect: (root: string) => ScanSource[]): number {\n const root = repoRoot(io.cwd);\n const allow = loadAllow(root);\n const { terms, loaded, notices } = loadTerms(io.env);\n for (const notice of notices) io.err(PREFIX + notice);\n const result = scan(collect(root), { terms, allow });\n for (const line of formatReport(result.findings, { ...result, termsLoaded: loaded })) io.out(line);\n return result.findings.length > 0 ? 1 : 0;\n}\n\nconst INSTALL_MESSAGES: Record<InstallResult[\"outcome\"], string> = {\n installed: \"pre-push hook installed\",\n updated: \"pre-push hook updated\",\n unchanged: \"pre-push hook already installed\",\n \"skipped-in-ci\": \"CI detected; hook not installed\",\n \"foreign-hook\": \"an existing pre-push hook was not written by this tool; left it in place.\",\n};\n\nfunction runInstall(io: CliIo): number {\n const result = installHook(io.cwd, io.env);\n const where = result.hookPath === undefined ? \"\" : ` (${result.hookPath})`;\n if (result.outcome !== \"foreign-hook\") {\n io.out(PREFIX + INSTALL_MESSAGES[result.outcome] + where);\n return 0;\n }\n io.err(PREFIX + INSTALL_MESSAGES[result.outcome] + where);\n io.err(PREFIX + 'chain it: call `titan-egress-scan pre-push \"$1\"` from that hook with git\\'s stdin passed through');\n return 2;\n}\n\nfunction expectArgs(command: string, args: readonly string[], min: number, max: number): void {\n if (args.length < min || args.length > max) throw new ConfigError(`${command}: wrong number of arguments`);\n}\n\n/** Rejects a value by its position only, so the message never repeats what was passed. */\nfunction expectValid(command: string, args: readonly string[], valid: (value: string) => boolean, what: string): void {\n args.forEach((value, i) => {\n if (!valid(value)) throw new ConfigError(`${command}: argument ${i + 1} is not ${what}`);\n });\n}\n\nfunction dispatch(command: string | undefined, args: readonly string[], io: CliIo): number {\n switch (command) {\n case \"pre-push\":\n // git passes the remote name and its URL; only the name is used.\n expectArgs(command, args, 1, 2);\n expectValid(command, args.slice(0, 1), isRemoteName, \"a remote name\");\n return runScan(io, (root) => prePushSources(root, args[0] ?? \"\", io));\n case \"range\":\n expectArgs(command, args, 2, 2);\n expectValid(command, args, isRevision, \"a sha or ref name\");\n return runScan(io, (root) =>\n readCommits(root, commitsForRange(root, args[0] ?? \"\", args[1] ?? \"\"), io.maxPatchBytes),\n );\n case \"tree\":\n expectArgs(command, args, 0, 0);\n return runScan(io, (root) => [readTree(root, io.maxPatchBytes)]);\n case \"install-hook\":\n expectArgs(command, args, 0, 0);\n return runInstall(io);\n default:\n throw new ConfigError(command === undefined ? \"missing command\" : \"unknown command\");\n }\n}\n\n/** Help counts only before the command; after it, a help flag is a usage error, never a skipped scan. */\nfunction parseCommandLine(argv: readonly string[]): { positionals: string[]; help: boolean } {\n const { positionals, tokens } = parseArgs({\n args: [...argv],\n allowPositionals: true,\n tokens: true,\n options: { help: { type: \"boolean\", short: \"h\" } },\n });\n const command = tokens.find((token) => token.kind === \"positional\");\n const helps = tokens.filter((token) => token.kind === \"option\" && token.name === \"help\");\n if (helps.some((token) => command !== undefined && token.index > command.index)) {\n throw new ConfigError(\"a help flag after the command is not allowed\");\n }\n return { positionals, help: helps.length > 0 };\n}\n\n/** Runs one command. Errors print as one line to stderr and exit 2; nothing echoes scanned text. */\nexport function runCli(argv: readonly string[], io: CliIo): number {\n try {\n const { positionals, help } = parseCommandLine(argv);\n if (help) {\n for (const line of USAGE) io.out(line);\n return 0;\n }\n return dispatch(positionals[0], positionals.slice(1), io);\n } catch (error) {\n io.err(PREFIX + (error instanceof Error ? error.message : \"unexpected error\"));\n if (error instanceof ConfigError) io.err(USAGE[0] ?? \"\");\n return 2;\n }\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { AllowFileError, EMPTY_ALLOW, parseAllow, type AllowList } from \"./allow.js\";\nimport type { TermRule } from \"./rules.js\";\nimport { parseTerms, TermFileError } from \"./terms.js\";\n\n/** A usage or configuration problem: exit 2. Messages never carry a term or scanned text. */\nexport class ConfigError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"ConfigError\";\n }\n}\n\nexport interface LoadedTerms {\n readonly terms: readonly TermRule[];\n readonly loaded: boolean;\n readonly notices: readonly string[];\n}\n\ntype Env = Readonly<Record<string, string | undefined>>;\n\nconst NOT_LOADED: LoadedTerms = { terms: [], loaded: false, notices: [] };\n\nexport function isCi(env: Env): boolean {\n const value = env.CI?.toLowerCase();\n return value !== undefined && value !== \"\" && value !== \"false\" && value !== \"0\";\n}\n\nexport function termFilePath(env: Env): string | undefined {\n if (env.TITAN_EGRESS_TERMS) return env.TITAN_EGRESS_TERMS;\n const configHome = env.XDG_CONFIG_HOME || (env.HOME && path.join(env.HOME, \".config\"));\n return configHome ? path.join(configHome, \"titan-egress\", \"private-terms\") : undefined;\n}\n\nfunction readIfPresent(file: string, what: string): string | undefined {\n try {\n return fs.readFileSync(file, \"utf-8\");\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n throw new ConfigError(`${what} could not be read`);\n }\n}\n\nconst ON_VALUES = new Set([\"1\", \"true\", \"yes\", \"on\"]);\nconst OFF_VALUES = new Set([\"\", \"0\", \"false\", \"no\", \"off\"]);\n\n/** An unrecognised value fails the scan: a typo must never read as off. The value is not echoed. */\nfunction requiresTerms(env: Env): boolean {\n const value = (env.TITAN_EGRESS_REQUIRE_TERMS ?? \"\").trim().toLowerCase();\n if (ON_VALUES.has(value)) return true;\n if (OFF_VALUES.has(value)) return false;\n throw new ConfigError(\"TITAN_EGRESS_REQUIRE_TERMS is not one of 1, true, yes, on, 0, false, no, off\");\n}\n\nfunction readTermFile(file: string, required: boolean): string | undefined {\n try {\n return readIfPresent(file, \"private term list\");\n } catch (error) {\n if (required && error instanceof ConfigError) {\n throw new ConfigError(\"private term list could not be read and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n throw error;\n }\n}\n\nfunction missingTerms(required: boolean): LoadedTerms {\n if (required) {\n throw new ConfigError(\"private term list not found and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n return { ...NOT_LOADED, notices: [\"private term list not found; generic rules only\"] };\n}\n\nfunction compileTerms(text: string): readonly TermRule[] {\n try {\n return parseTerms(text);\n } catch (error) {\n if (error instanceof TermFileError) throw new ConfigError(error.message);\n throw error;\n }\n}\n\n/**\n * Loads the private term list. In CI it is never looked up and an absent file is a notice, unless\n * TITAN_EGRESS_REQUIRE_TERMS=1, which always loads and fails on a missing, unreadable or empty list.\n */\nexport function loadTerms(env: Env): LoadedTerms {\n const required = requiresTerms(env);\n if (isCi(env) && !required) return NOT_LOADED;\n const file = termFilePath(env);\n const text = file === undefined ? undefined : readTermFile(file, required);\n if (file === undefined || text === undefined) return missingTerms(required);\n const openToOthers = process.platform !== \"win32\" && (fs.statSync(file).mode & 0o077) !== 0;\n const notices = openToOthers ? [\"private term list is readable by other users; chmod 600 it\"] : [];\n const terms = compileTerms(text);\n if (terms.length === 0 && required) {\n throw new ConfigError(\"private term list holds no terms and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n return { terms, loaded: true, notices };\n}\n\n/** Loads `.egress-allow` from the repo root. A malformed file fails the scan, never reads as empty. */\nexport function loadAllow(root: string): AllowList {\n const text = readIfPresent(path.join(root, \".egress-allow\"), \".egress-allow\");\n if (text === undefined) return EMPTY_ALLOW;\n try {\n return parseAllow(text);\n } catch (error) {\n if (error instanceof AllowFileError) throw new ConfigError(error.message);\n throw error;\n }\n}\n","import { spawnSync, type SpawnSyncReturns } from \"node:child_process\";\nimport * as path from \"node:path\";\nimport { ConfigError } from \"./config.js\";\nimport { parseDiff, type ScanSource } from \"./diff.js\";\n\n/** A git command that failed. The message names the subcommand, never git's output. */\nexport class GitError extends Error {\n constructor(subcommand: string, status: number | null) {\n super(`git ${subcommand} failed (exit ${status ?? \"signal\"}); run it by hand to see git's error`);\n this.name = \"GitError\";\n }\n}\n\nexport interface PushUpdate {\n readonly localSha: string;\n readonly remoteSha: string;\n}\n\n/** A commit or tree whose patch text passes this many bytes is refused: over it, V8 cannot hold the text as one string. */\nexport const MAX_PATCH_BYTES = 128 * 1024 * 1024;\n\n/** A commit or tree too large to scan. The message names the source and the limit, never the content. */\nexport class PatchTooLargeError extends Error {\n constructor(sha: string | undefined, limit: number) {\n super(`${sha === undefined ? \"tree\" : `commit ${sha.slice(0, 7)}`}: patch text is over the scan limit of ${formatBytes(limit)}; refusing it`);\n this.name = \"PatchTooLargeError\";\n }\n}\n\nfunction formatBytes(bytes: number): string {\n const mib = 1024 * 1024;\n return bytes % mib === 0 ? `${bytes / mib} MiB` : `${bytes} bytes`;\n}\n\n// Pinned so user config (prefixes, textconv, relative, external diff) cannot reshape the patch text.\n// `--text` diffs a file git calls binary, so one NUL byte cannot hide the lines around it.\nconst PATCH_FLAGS = [\n \"--text\",\n \"-U0\",\n \"--no-color\",\n \"--no-ext-diff\",\n \"--no-textconv\",\n \"--no-relative\",\n \"--src-prefix=a/\",\n \"--dst-prefix=b/\",\n];\n// A message re-encoded by `i18n.logOutputEncoding` (UTF-16, say) would slip past every rule.\nconst MESSAGE_FLAGS = [\"--no-patch\", \"--encoding=UTF-8\", \"--format=%B\"];\n// A combined diff ignores `--text`, so a merge is diffed against each parent in turn instead.\nconst COMMIT_PATCH_FLAGS = [\"--diff-merges=separate\", \"--format=\", ...PATCH_FLAGS];\nconst MAX_BUFFER = 1024 * 1024 * 1024;\n// Every revision argument follows this, so a value that starts with a dash cannot become an option.\nconst END_OF_OPTIONS = \"--end-of-options\";\n\nconst FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;\nconst REVISION = /^(?:[0-9a-f]{7,64}|[^-\\s]\\S*)$/;\nconst REMOTE_NAME = /^[^-\\s]\\S*$/;\n\nexport function isFullSha(value: string): boolean {\n return FULL_SHA.test(value);\n}\n\n/** A hex sha of 7 to 64 characters, or a ref name that cannot be read as an option. */\nexport function isRevision(value: string): boolean {\n return REVISION.test(value);\n}\n\nexport function isRemoteName(value: string): boolean {\n return REMOTE_NAME.test(value);\n}\n\nfunction requireRevision(value: string, what: string): void {\n if (!isRevision(value)) throw new ConfigError(`${what} is not a sha or ref name`);\n}\n\nfunction runGit(cwd: string, args: readonly string[], maxBuffer: number, input?: string): SpawnSyncReturns<string> {\n return spawnSync(\"git\", args, { cwd, input, encoding: \"utf-8\", maxBuffer });\n}\n\nfunction stdoutOf(result: SpawnSyncReturns<string>, subcommand: string): string {\n if (result.error) throw result.error;\n if (result.status !== 0) throw new GitError(subcommand, result.status);\n return result.stdout;\n}\n\nexport function git(cwd: string, args: readonly string[], input?: string): string {\n return stdoutOf(runGit(cwd, args, MAX_BUFFER, input), args[0] ?? \"\");\n}\n\nfunction lines(text: string): string[] {\n return text.split(\"\\n\").filter((line) => line !== \"\");\n}\n\nexport function isZeroSha(sha: string): boolean {\n return /^0+$/.test(sha);\n}\n\nexport function repoRoot(cwd: string): string {\n return git(cwd, [\"rev-parse\", \"--show-toplevel\"]).trim();\n}\n\n/** Resolves the hooks directory as git does: `core.hooksPath` if set, else the common git dir's hooks. */\nexport function hooksDir(cwd: string): string {\n return path.resolve(cwd, git(cwd, [\"rev-parse\", \"--git-path\", \"hooks\"]).trim());\n}\n\n/** Parses git's pre-push stdin: `<local-ref> <local-sha> <remote-ref> <remote-sha>` per line. */\nexport function parsePrePush(stdin: string): PushUpdate[] {\n return lines(stdin).map((line, i) => {\n const fields = line.trim().split(/\\s+/);\n if (fields.length !== 4) throw new ConfigError(`pre-push stdin line ${i + 1}: expected four fields`);\n for (const field of [1, 3]) {\n if (!isFullSha(fields[field] ?? \"\")) {\n throw new ConfigError(`pre-push stdin line ${i + 1}: field ${field + 1} is not a full sha`);\n }\n }\n return { localSha: fields[1] ?? \"\", remoteSha: fields[3] ?? \"\" };\n });\n}\n\nfunction hasCommit(cwd: string, sha: string): boolean {\n return spawnSync(\"git\", [\"cat-file\", \"-e\", END_OF_OPTIONS, `${sha}^{commit}`], { cwd }).status === 0;\n}\n\n/** The commits one pushed ref update sends that the remote lacks, oldest first. */\nexport function commitsForUpdate(cwd: string, remote: string, update: PushUpdate): string[] {\n if (isZeroSha(update.localSha)) return [];\n if (!isFullSha(update.localSha) || !isFullSha(update.remoteSha)) throw new ConfigError(\"push update is not a full sha\");\n if (!isRemoteName(remote)) throw new ConfigError(\"remote is not a remote name\");\n const known = !isZeroSha(update.remoteSha) && hasCommit(cwd, update.remoteSha);\n // The second `--not` flips back, so the local sha after it counts as included.\n const range = known\n ? [END_OF_OPTIONS, `${update.remoteSha}..${update.localSha}`]\n : [\"--not\", `--remotes=${remote}`, \"--not\", END_OF_OPTIONS, update.localSha];\n return lines(git(cwd, [\"rev-list\", \"--reverse\", ...range]));\n}\n\n/** The commits in `base..head`, oldest first; an all-zero base means `head` alone. */\nexport function commitsForRange(cwd: string, base: string, head: string): string[] {\n requireRevision(base, \"range base\");\n requireRevision(head, \"range head\");\n if (isZeroSha(base)) return lines(git(cwd, [\"rev-list\", \"--no-walk\", END_OF_OPTIONS, head]));\n return lines(git(cwd, [\"rev-list\", \"--reverse\", END_OF_OPTIONS, `${base}..${head}`]));\n}\n\nfunction isOverBuffer(error: Error | undefined): boolean {\n return (error as NodeJS.ErrnoException | undefined)?.code === \"ENOBUFS\";\n}\n\n/** Refuses a source in which git still skipped a file as binary, since its lines went unscanned. */\nfunction requireAllText(source: ScanSource, what: string): ScanSource {\n if (source.binaryFiles > 0) throw new Error(`${what}: git printed a file as binary despite --text; refusing it`);\n return source;\n}\n\nfunction showCommit(cwd: string, sha: string, flags: readonly string[], maxBytes: number): string {\n const result = runGit(cwd, [\"show\", ...flags, END_OF_OPTIONS, sha], maxBytes);\n if (isOverBuffer(result.error)) throw new PatchTooLargeError(sha, maxBytes);\n return stdoutOf(result, \"show\");\n}\n\n/**\n * One commit's message and patch, read by separate calls so a merge's per-parent copies of the\n * message never land inside its patch. Throws `PatchTooLargeError` when either is over `maxPatchBytes`.\n */\nexport function readCommit(cwd: string, sha: string, maxPatchBytes = MAX_PATCH_BYTES): ScanSource {\n requireRevision(sha, \"commit\");\n const message = showCommit(cwd, sha, MESSAGE_FLAGS, maxPatchBytes).replace(/\\n+$/, \"\").split(\"\\n\");\n const patch = parseDiff(showCommit(cwd, sha, COMMIT_PATCH_FLAGS, maxPatchBytes));\n return requireAllText({ ...patch, sha, message }, `commit ${sha.slice(0, 7)}`);\n}\n\n/** Every tracked file at HEAD, as one diff from the empty tree. Throws `PatchTooLargeError` over `maxPatchBytes`. */\nexport function readTree(cwd: string, maxPatchBytes = MAX_PATCH_BYTES): ScanSource {\n const emptyTree = git(cwd, [\"hash-object\", \"-t\", \"tree\", \"--stdin\"], \"\").trim();\n const args = [\"diff\", ...PATCH_FLAGS, \"--no-renames\", END_OF_OPTIONS, emptyTree, \"HEAD\"];\n const result = runGit(cwd, args, maxPatchBytes);\n if (isOverBuffer(result.error)) throw new PatchTooLargeError(undefined, maxPatchBytes);\n return requireAllText(parseDiff(stdoutOf(result, \"diff\")), \"tree\");\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { isCi } from \"./config.js\";\nimport { hooksDir } from \"./git.js\";\n\nexport const HOOK_MARKER = \"# titan-egress-scan managed pre-push hook\";\n\nexport type InstallOutcome = \"installed\" | \"updated\" | \"unchanged\" | \"skipped-in-ci\" | \"foreign-hook\";\n\nexport interface InstallResult {\n readonly outcome: InstallOutcome;\n readonly hookPath?: string;\n}\n\n/** The shipped hook body, one directory above both `src/` and `dist/`. */\nexport function hookBody(): string {\n return fs.readFileSync(new URL(\"../hooks/pre-push\", import.meta.url), \"utf-8\");\n}\n\nfunction existing(hookPath: string): string | undefined {\n return fs.existsSync(hookPath) ? fs.readFileSync(hookPath, \"utf-8\") : undefined;\n}\n\n/**\n * Writes the pre-push hook into git's hooks directory, which honours `core.hooksPath` and is\n * shared by every linked worktree. It never sets `core.hooksPath` and never replaces a hook it\n * did not write.\n */\nexport function installHook(cwd: string, env: Readonly<Record<string, string | undefined>>): InstallResult {\n if (isCi(env)) return { outcome: \"skipped-in-ci\" };\n const hookPath = path.join(hooksDir(cwd), \"pre-push\");\n const body = hookBody();\n const current = existing(hookPath);\n if (current !== undefined && !current.includes(HOOK_MARKER)) return { outcome: \"foreign-hook\", hookPath };\n if (current === body) return { outcome: \"unchanged\", hookPath };\n fs.mkdirSync(path.dirname(hookPath), { recursive: true });\n fs.writeFileSync(hookPath, body, { mode: 0o755 });\n fs.chmodSync(hookPath, 0o755);\n return { outcome: current === undefined ? \"installed\" : \"updated\", hookPath };\n}\n"],"mappings":"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","names":["fs","path","fs","path"]}
1
+ {"version":3,"sources":["../src/bin.ts","../src/cli.ts","../src/config.ts","../src/git.ts","../src/install.ts"],"sourcesContent":["#!/usr/bin/env node\nimport * as fs from \"node:fs\";\nimport { runCli } from \"./cli.js\";\n\nprocess.exitCode = runCli(process.argv.slice(2), {\n cwd: process.cwd(),\n env: process.env,\n readStdin: () => fs.readFileSync(0, \"utf-8\"),\n readFile: (file) => fs.readFileSync(file, \"utf-8\"),\n out: (line) => process.stdout.write(`${line}\\n`),\n err: (line) => process.stderr.write(`${line}\\n`),\n});\n","import { parseArgs } from \"node:util\";\nimport { ConfigError, loadAllow, loadTerms } from \"./config.js\";\nimport type { ScanSource } from \"./diff.js\";\nimport {\n commitsForRange,\n commitsForUpdate,\n formatBytes,\n isRemoteName,\n isRevision,\n MAX_PATCH_BYTES,\n parsePrePush,\n readCommit,\n readTree,\n refSource,\n repoRoot,\n} from \"./git.js\";\nimport { installHook, type InstallResult } from \"./install.js\";\nimport { formatReport } from \"./report.js\";\nimport { scan, zeroCounts } from \"./scan.js\";\nimport { scanText } from \"./text.js\";\n\nexport interface CliIo {\n readonly cwd: string;\n readonly env: Readonly<Record<string, string | undefined>>;\n readStdin(): string;\n readFile(path: string): string;\n out(line: string): void;\n err(line: string): void;\n /** Overrides `MAX_PATCH_BYTES` for tests; the bin never sets it. */\n readonly maxPatchBytes?: number;\n}\n\nconst USAGE = [\n \"usage: titan-egress-scan <command>\",\n \" pre-push <remote> scan the commits a push sends (reads git's pre-push stdin)\",\n \" range <base> <head> scan every commit in base..head (CI)\",\n \" tree scan every tracked file at HEAD\",\n \" text [--file <path>] scan free text from stdin or a file, reporting line:col and rule id\",\n \" install-hook install the pre-push hook into git's hooks directory\",\n \"files git calls binary are scanned as text; a commit or tree over 128 MiB of patch text exits 2\",\n \"exit: 0 clean, 1 findings, 2 usage or configuration error\",\n];\n\nconst PREFIX = \"titan-egress-scan: \";\n\nfunction readCommits(root: string, shas: readonly string[], maxPatchBytes?: number): ScanSource[] {\n return [...new Set(shas)].map((sha) => readCommit(root, sha, maxPatchBytes));\n}\n\nfunction prePushSources(root: string, remote: string, io: CliIo): ScanSource[] {\n const pushLines = parsePrePush(io.readStdin());\n const shas = pushLines.flatMap((line) => commitsForUpdate(root, remote, line));\n return [refSource(pushLines), ...readCommits(root, shas, io.maxPatchBytes)];\n}\n\nfunction runScan(io: CliIo, collect: (root: string) => ScanSource[]): number {\n const root = repoRoot(io.cwd);\n const allow = loadAllow(root);\n const { terms, loaded, notices } = loadTerms(io.env);\n for (const notice of notices) io.err(PREFIX + notice);\n const result = scan(collect(root), { terms, allow });\n for (const line of formatReport(result.findings, { ...result, termsLoaded: loaded })) io.out(line);\n return result.findings.length > 0 ? 1 : 0;\n}\n\nfunction readTextInput(file: string | undefined, io: CliIo): string {\n if (file === undefined) return io.readStdin();\n try {\n return io.readFile(file);\n } catch {\n throw new ConfigError(\"text: --file could not be read\");\n }\n}\n\nfunction runText(file: string | undefined, io: CliIo): number {\n const text = readTextInput(file, io);\n const limit = io.maxPatchBytes ?? MAX_PATCH_BYTES;\n if (Buffer.byteLength(text) > limit) {\n throw new ConfigError(`text: input is over the scan limit of ${formatBytes(limit)}; refusing it`);\n }\n const { terms, loaded, notices } = loadTerms(io.env);\n for (const notice of notices) io.err(PREFIX + notice);\n const findings = scanText(text, { terms });\n const summary = { allowed: zeroCounts(), binaryFilesSkipped: 0, termsLoaded: loaded };\n for (const line of formatReport(findings, summary)) io.out(line);\n return findings.length > 0 ? 1 : 0;\n}\n\nconst INSTALL_MESSAGES: Record<InstallResult[\"outcome\"], string> = {\n installed: \"pre-push hook installed\",\n updated: \"pre-push hook updated\",\n unchanged: \"pre-push hook already installed\",\n \"skipped-in-ci\": \"CI detected; hook not installed\",\n \"foreign-hook\": \"an existing pre-push hook was not written by this tool; left it in place.\",\n};\n\nfunction runInstall(io: CliIo): number {\n const result = installHook(io.cwd, io.env);\n const where = result.hookPath === undefined ? \"\" : ` (${result.hookPath})`;\n if (result.outcome !== \"foreign-hook\") {\n io.out(PREFIX + INSTALL_MESSAGES[result.outcome] + where);\n return 0;\n }\n io.err(PREFIX + INSTALL_MESSAGES[result.outcome] + where);\n io.err(PREFIX + 'chain it: call `titan-egress-scan pre-push \"$1\"` from that hook with git\\'s stdin passed through');\n return 2;\n}\n\nfunction expectArgs(command: string, args: readonly string[], min: number, max: number): void {\n if (args.length < min || args.length > max) throw new ConfigError(`${command}: wrong number of arguments`);\n}\n\n/** Rejects a value by its position only, so the message never repeats what was passed. */\nfunction expectValid(command: string, args: readonly string[], valid: (value: string) => boolean, what: string): void {\n args.forEach((value, i) => {\n if (!valid(value)) throw new ConfigError(`${command}: argument ${i + 1} is not ${what}`);\n });\n}\n\nfunction dispatch(command: string | undefined, args: readonly string[], file: string | undefined, io: CliIo): number {\n if (file !== undefined && command !== \"text\") throw new ConfigError(\"--file is only valid for text\");\n switch (command) {\n case \"pre-push\":\n // git passes the remote name and its URL; only the name is used.\n expectArgs(command, args, 1, 2);\n expectValid(command, args.slice(0, 1), isRemoteName, \"a remote name\");\n return runScan(io, (root) => prePushSources(root, args[0] ?? \"\", io));\n case \"range\":\n expectArgs(command, args, 2, 2);\n expectValid(command, args, isRevision, \"a sha or ref name\");\n return runScan(io, (root) =>\n readCommits(root, commitsForRange(root, args[0] ?? \"\", args[1] ?? \"\"), io.maxPatchBytes),\n );\n case \"tree\":\n expectArgs(command, args, 0, 0);\n return runScan(io, (root) => [readTree(root, io.maxPatchBytes)]);\n case \"text\":\n expectArgs(command, args, 0, 0);\n return runText(file, io);\n case \"install-hook\":\n expectArgs(command, args, 0, 0);\n return runInstall(io);\n default:\n throw new ConfigError(command === undefined ? \"missing command\" : \"unknown command\");\n }\n}\n\n/** parseArgs messages quote the offending argument, so they are replaced rather than passed on. */\nfunction parseOptions(argv: readonly string[]) {\n try {\n return parseArgs({\n args: [...argv],\n allowPositionals: true,\n tokens: true,\n options: { help: { type: \"boolean\", short: \"h\" }, file: { type: \"string\" } },\n });\n } catch {\n throw new ConfigError(\"unknown option or missing option value (not shown)\");\n }\n}\n\n/** Help counts only before the command; after it, a help flag is a usage error, never a skipped scan. */\nfunction parseCommandLine(argv: readonly string[]): { positionals: string[]; help: boolean; file?: string } {\n const { positionals, tokens, values } = parseOptions(argv);\n const command = tokens.find((token) => token.kind === \"positional\");\n const helps = tokens.filter((token) => token.kind === \"option\" && token.name === \"help\");\n if (helps.some((token) => command !== undefined && token.index > command.index)) {\n throw new ConfigError(\"a help flag after the command is not allowed\");\n }\n if (tokens.filter((token) => token.kind === \"option\" && token.name === \"file\").length > 1) {\n throw new ConfigError(\"--file may be given only once\");\n }\n return { positionals, help: helps.length > 0, file: values.file };\n}\n\n/** Runs one command. Errors print as one line to stderr and exit 2; nothing echoes scanned text. */\nexport function runCli(argv: readonly string[], io: CliIo): number {\n try {\n const { positionals, help, file } = parseCommandLine(argv);\n if (help) {\n for (const line of USAGE) io.out(line);\n return 0;\n }\n return dispatch(positionals[0], positionals.slice(1), file, io);\n } catch (error) {\n io.err(PREFIX + (error instanceof Error ? error.message : \"unexpected error\"));\n if (error instanceof ConfigError) io.err(USAGE[0] ?? \"\");\n return 2;\n }\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { AllowFileError, EMPTY_ALLOW, parseAllow, type AllowList } from \"./allow.js\";\nimport type { TermRule } from \"./rules.js\";\nimport { parseTerms, TermFileError } from \"./terms.js\";\n\n/** A usage or configuration problem: exit 2. Messages never carry a term or scanned text. */\nexport class ConfigError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"ConfigError\";\n }\n}\n\nexport interface LoadedTerms {\n readonly terms: readonly TermRule[];\n readonly loaded: boolean;\n readonly notices: readonly string[];\n}\n\ntype Env = Readonly<Record<string, string | undefined>>;\n\nconst NOT_LOADED: LoadedTerms = { terms: [], loaded: false, notices: [] };\n\nexport function isCi(env: Env): boolean {\n const value = env.CI?.toLowerCase();\n return value !== undefined && value !== \"\" && value !== \"false\" && value !== \"0\";\n}\n\nexport function termFilePath(env: Env): string | undefined {\n if (env.TITAN_EGRESS_TERMS) return env.TITAN_EGRESS_TERMS;\n const configHome = env.XDG_CONFIG_HOME || (env.HOME && path.join(env.HOME, \".config\"));\n return configHome ? path.join(configHome, \"titan-egress\", \"private-terms\") : undefined;\n}\n\nfunction readIfPresent(file: string, what: string): string | undefined {\n try {\n return fs.readFileSync(file, \"utf-8\");\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return undefined;\n throw new ConfigError(`${what} could not be read`);\n }\n}\n\nconst ON_VALUES = new Set([\"1\", \"true\", \"yes\", \"on\"]);\nconst OFF_VALUES = new Set([\"\", \"0\", \"false\", \"no\", \"off\"]);\n\n/** An unrecognised value fails the scan: a typo must never read as off. The value is not echoed. */\nfunction requiresTerms(env: Env): boolean {\n const value = (env.TITAN_EGRESS_REQUIRE_TERMS ?? \"\").trim().toLowerCase();\n if (ON_VALUES.has(value)) return true;\n if (OFF_VALUES.has(value)) return false;\n throw new ConfigError(\"TITAN_EGRESS_REQUIRE_TERMS is not one of 1, true, yes, on, 0, false, no, off\");\n}\n\nfunction readTermFile(file: string, required: boolean): string | undefined {\n try {\n return readIfPresent(file, \"private term list\");\n } catch (error) {\n if (required && error instanceof ConfigError) {\n throw new ConfigError(\"private term list could not be read and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n throw error;\n }\n}\n\nfunction missingTerms(required: boolean): LoadedTerms {\n if (required) {\n throw new ConfigError(\"private term list not found and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n return { ...NOT_LOADED, notices: [\"private term list not found; generic rules only\"] };\n}\n\nfunction compileTerms(text: string): readonly TermRule[] {\n try {\n return parseTerms(text);\n } catch (error) {\n if (error instanceof TermFileError) throw new ConfigError(error.message);\n throw error;\n }\n}\n\n/**\n * Loads the private term list. In CI it is never looked up and an absent file is a notice, unless\n * TITAN_EGRESS_REQUIRE_TERMS=1, which always loads and fails on a missing, unreadable or empty list.\n */\nexport function loadTerms(env: Env): LoadedTerms {\n const required = requiresTerms(env);\n if (isCi(env) && !required) return NOT_LOADED;\n const file = termFilePath(env);\n const text = file === undefined ? undefined : readTermFile(file, required);\n if (file === undefined || text === undefined) return missingTerms(required);\n const openToOthers = process.platform !== \"win32\" && (fs.statSync(file).mode & 0o077) !== 0;\n const notices = openToOthers ? [\"private term list is readable by other users; chmod 600 it\"] : [];\n const terms = compileTerms(text);\n if (terms.length === 0 && required) {\n throw new ConfigError(\"private term list holds no terms and TITAN_EGRESS_REQUIRE_TERMS=1\");\n }\n return { terms, loaded: true, notices };\n}\n\n/** Loads `.egress-allow` from the repo root. A malformed file fails the scan, never reads as empty. */\nexport function loadAllow(root: string): AllowList {\n const text = readIfPresent(path.join(root, \".egress-allow\"), \".egress-allow\");\n if (text === undefined) return EMPTY_ALLOW;\n try {\n return parseAllow(text);\n } catch (error) {\n if (error instanceof AllowFileError) throw new ConfigError(error.message);\n throw error;\n }\n}\n","import { spawnSync, type SpawnSyncReturns } from \"node:child_process\";\nimport * as path from \"node:path\";\nimport { ConfigError } from \"./config.js\";\nimport { parseDiff, type IdentField, type ScanSource } from \"./diff.js\";\n\n/** A git command that failed. The message names the subcommand, never git's output. */\nexport class GitError extends Error {\n constructor(subcommand: string, status: number | null) {\n super(`git ${subcommand} failed (exit ${status ?? \"signal\"}); run it by hand to see git's error`);\n this.name = \"GitError\";\n }\n}\n\nexport interface PushUpdate {\n readonly localSha: string;\n readonly remoteSha: string;\n}\n\n/** One pre-push stdin line: the shas plus the local and remote ref names, which leave the machine too. */\nexport interface PushLine extends PushUpdate {\n readonly localRef: string;\n readonly remoteRef: string;\n}\n\n/** A commit or tree whose patch text passes this many bytes is refused: over it, V8 cannot hold the text as one string. */\nexport const MAX_PATCH_BYTES = 128 * 1024 * 1024;\n\n/** A commit or tree too large to scan. The message names the source and the limit, never the content. */\nexport class PatchTooLargeError extends Error {\n constructor(sha: string | undefined, limit: number) {\n super(`${sha === undefined ? \"tree\" : `commit ${sha.slice(0, 7)}`}: patch text is over the scan limit of ${formatBytes(limit)}; refusing it`);\n this.name = \"PatchTooLargeError\";\n }\n}\n\nexport function formatBytes(bytes: number): string {\n const mib = 1024 * 1024;\n return bytes % mib === 0 ? `${bytes / mib} MiB` : `${bytes} bytes`;\n}\n\n// Pinned so user config (prefixes, textconv, relative, external diff) cannot reshape the patch text.\n// `--text` diffs a file git calls binary, so one NUL byte cannot hide the lines around it.\nconst PATCH_FLAGS = [\n \"--text\",\n \"-U0\",\n \"--no-color\",\n \"--no-ext-diff\",\n \"--no-textconv\",\n \"--no-relative\",\n \"--src-prefix=a/\",\n \"--dst-prefix=b/\",\n];\n// A message re-encoded by `i18n.logOutputEncoding` (UTF-16, say) would slip past every rule.\n// The raw `%an` forms, not the mailmapped `%aN`, since the raw ident is what the push sends.\nconst MESSAGE_FLAGS = [\"--no-patch\", \"--encoding=UTF-8\", \"--format=%an%x00%ae%x00%cn%x00%ce%x00%B\"];\nconst IDENT_FIELDS = [\"author.name\", \"author.email\", \"committer.name\", \"committer.email\"];\n// A combined diff ignores `--text`, so a merge is diffed against each parent in turn instead.\nconst COMMIT_PATCH_FLAGS = [\"--diff-merges=separate\", \"--format=\", ...PATCH_FLAGS];\nconst MAX_BUFFER = 1024 * 1024 * 1024;\n// Every revision argument follows this, so a value that starts with a dash cannot become an option.\nconst END_OF_OPTIONS = \"--end-of-options\";\n\nconst FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;\nconst REVISION = /^(?:[0-9a-f]{7,64}|[^-\\s]\\S*)$/;\nconst REMOTE_NAME = /^[^-\\s]\\S*$/;\n\nexport function isFullSha(value: string): boolean {\n return FULL_SHA.test(value);\n}\n\n/** A hex sha of 7 to 64 characters, or a ref name that cannot be read as an option. */\nexport function isRevision(value: string): boolean {\n return REVISION.test(value);\n}\n\nexport function isRemoteName(value: string): boolean {\n return REMOTE_NAME.test(value);\n}\n\nfunction requireRevision(value: string, what: string): void {\n if (!isRevision(value)) throw new ConfigError(`${what} is not a sha or ref name`);\n}\n\nfunction runGit(cwd: string, args: readonly string[], maxBuffer: number, input?: string): SpawnSyncReturns<string> {\n return spawnSync(\"git\", args, { cwd, input, encoding: \"utf-8\", maxBuffer });\n}\n\nfunction stdoutOf(result: SpawnSyncReturns<string>, subcommand: string): string {\n if (result.error) throw result.error;\n if (result.status !== 0) throw new GitError(subcommand, result.status);\n return result.stdout;\n}\n\nexport function git(cwd: string, args: readonly string[], input?: string): string {\n return stdoutOf(runGit(cwd, args, MAX_BUFFER, input), args[0] ?? \"\");\n}\n\nfunction lines(text: string): string[] {\n return text.split(\"\\n\").filter((line) => line !== \"\");\n}\n\nexport function isZeroSha(sha: string): boolean {\n return /^0+$/.test(sha);\n}\n\nexport function repoRoot(cwd: string): string {\n return git(cwd, [\"rev-parse\", \"--show-toplevel\"]).trim();\n}\n\n/** Resolves the hooks directory as git does: `core.hooksPath` if set, else the common git dir's hooks. */\nexport function hooksDir(cwd: string): string {\n return path.resolve(cwd, git(cwd, [\"rev-parse\", \"--git-path\", \"hooks\"]).trim());\n}\n\n/** Parses git's pre-push stdin: `<local-ref> <local-sha> <remote-ref> <remote-sha>` per line. */\nexport function parsePrePush(stdin: string): PushLine[] {\n return lines(stdin).map((line, i) => {\n const fields = line.trim().split(/\\s+/);\n if (fields.length !== 4) throw new ConfigError(`pre-push stdin line ${i + 1}: expected four fields`);\n for (const field of [1, 3]) {\n if (!isFullSha(fields[field] ?? \"\")) {\n throw new ConfigError(`pre-push stdin line ${i + 1}: field ${field + 1} is not a full sha`);\n }\n }\n return { localRef: fields[0] ?? \"\", localSha: fields[1] ?? \"\", remoteRef: fields[2] ?? \"\", remoteSha: fields[3] ?? \"\" };\n });\n}\n\n/** The ref names a push sends, as idents named `push line <n> local ref` and `remote ref`; deletions are skipped. */\nexport function refSource(pushLines: readonly PushLine[]): ScanSource {\n const idents = pushLines.flatMap((line, i): IdentField[] =>\n isZeroSha(line.localSha)\n ? []\n : [\n { field: `push line ${i + 1} local ref`, text: line.localRef },\n { field: `push line ${i + 1} remote ref`, text: line.remoteRef },\n ],\n );\n return { files: [], binaryFiles: 0, idents };\n}\n\nfunction hasCommit(cwd: string, sha: string): boolean {\n return spawnSync(\"git\", [\"cat-file\", \"-e\", END_OF_OPTIONS, `${sha}^{commit}`], { cwd }).status === 0;\n}\n\n/** The commits one pushed ref update sends that the remote lacks, oldest first. */\nexport function commitsForUpdate(cwd: string, remote: string, update: PushUpdate): string[] {\n if (isZeroSha(update.localSha)) return [];\n if (!isFullSha(update.localSha) || !isFullSha(update.remoteSha)) throw new ConfigError(\"push update is not a full sha\");\n if (!isRemoteName(remote)) throw new ConfigError(\"remote is not a remote name\");\n const known = !isZeroSha(update.remoteSha) && hasCommit(cwd, update.remoteSha);\n // The second `--not` flips back, so the local sha after it counts as included.\n const range = known\n ? [END_OF_OPTIONS, `${update.remoteSha}..${update.localSha}`]\n : [\"--not\", `--remotes=${remote}`, \"--not\", END_OF_OPTIONS, update.localSha];\n return lines(git(cwd, [\"rev-list\", \"--reverse\", ...range]));\n}\n\n/** The commits in `base..head`, oldest first; an all-zero base means `head` alone. */\nexport function commitsForRange(cwd: string, base: string, head: string): string[] {\n requireRevision(base, \"range base\");\n requireRevision(head, \"range head\");\n if (isZeroSha(base)) return lines(git(cwd, [\"rev-list\", \"--no-walk\", END_OF_OPTIONS, head]));\n return lines(git(cwd, [\"rev-list\", \"--reverse\", END_OF_OPTIONS, `${base}..${head}`]));\n}\n\nfunction isOverBuffer(error: Error | undefined): boolean {\n return (error as NodeJS.ErrnoException | undefined)?.code === \"ENOBUFS\";\n}\n\n/** Refuses a source in which git still skipped a file as binary, since its lines went unscanned. */\nfunction requireAllText(source: ScanSource, what: string): ScanSource {\n if (source.binaryFiles > 0) throw new Error(`${what}: git printed a file as binary despite --text; refusing it`);\n return source;\n}\n\nfunction showCommit(cwd: string, sha: string, flags: readonly string[], maxBytes: number): string {\n const result = runGit(cwd, [\"show\", ...flags, END_OF_OPTIONS, sha], maxBytes);\n if (isOverBuffer(result.error)) throw new PatchTooLargeError(sha, maxBytes);\n return stdoutOf(result, \"show\");\n}\n\nfunction splitHeader(text: string): { idents: IdentField[]; message: string[] } {\n const parts = text.split(\"\\0\");\n const idents = IDENT_FIELDS.map((field, i) => ({ field, text: parts[i] ?? \"\" }));\n // A NUL inside the message itself stays in the message rather than shifting the fields.\n const message = parts.slice(IDENT_FIELDS.length).join(\"\\0\").replace(/\\n+$/, \"\").split(\"\\n\");\n return { idents, message };\n}\n\n/**\n * One commit's idents, message and patch, read by separate calls so a merge's per-parent copies of\n * the message never land inside its patch. Throws `PatchTooLargeError` when either is over `maxPatchBytes`.\n */\nexport function readCommit(cwd: string, sha: string, maxPatchBytes = MAX_PATCH_BYTES): ScanSource {\n requireRevision(sha, \"commit\");\n const { idents, message } = splitHeader(showCommit(cwd, sha, MESSAGE_FLAGS, maxPatchBytes));\n const patch = parseDiff(showCommit(cwd, sha, COMMIT_PATCH_FLAGS, maxPatchBytes));\n return requireAllText({ ...patch, sha, message, idents }, `commit ${sha.slice(0, 7)}`);\n}\n\n/** Every tracked file at HEAD, as one diff from the empty tree. Throws `PatchTooLargeError` over `maxPatchBytes`. */\nexport function readTree(cwd: string, maxPatchBytes = MAX_PATCH_BYTES): ScanSource {\n const emptyTree = git(cwd, [\"hash-object\", \"-t\", \"tree\", \"--stdin\"], \"\").trim();\n const args = [\"diff\", ...PATCH_FLAGS, \"--no-renames\", END_OF_OPTIONS, emptyTree, \"HEAD\"];\n const result = runGit(cwd, args, maxPatchBytes);\n if (isOverBuffer(result.error)) throw new PatchTooLargeError(undefined, maxPatchBytes);\n return requireAllText(parseDiff(stdoutOf(result, \"diff\")), \"tree\");\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport { isCi } from \"./config.js\";\nimport { hooksDir } from \"./git.js\";\n\nexport const HOOK_MARKER = \"# titan-egress-scan managed pre-push hook\";\n\nexport type InstallOutcome = \"installed\" | \"updated\" | \"unchanged\" | \"skipped-in-ci\" | \"foreign-hook\";\n\nexport interface InstallResult {\n readonly outcome: InstallOutcome;\n readonly hookPath?: string;\n}\n\n/** The shipped hook body, one directory above both `src/` and `dist/`. */\nexport function hookBody(): string {\n return fs.readFileSync(new URL(\"../hooks/pre-push\", import.meta.url), \"utf-8\");\n}\n\nfunction existing(hookPath: string): string | undefined {\n return fs.existsSync(hookPath) ? fs.readFileSync(hookPath, \"utf-8\") : undefined;\n}\n\n/**\n * Writes the pre-push hook into git's hooks directory, which honours `core.hooksPath` and is\n * shared by every linked worktree. It never sets `core.hooksPath` and never replaces a hook it\n * did not write.\n */\nexport function installHook(cwd: string, env: Readonly<Record<string, string | undefined>>): InstallResult {\n if (isCi(env)) return { outcome: \"skipped-in-ci\" };\n const hookPath = path.join(hooksDir(cwd), \"pre-push\");\n const body = hookBody();\n const current = existing(hookPath);\n if (current !== undefined && !current.includes(HOOK_MARKER)) return { outcome: \"foreign-hook\", hookPath };\n if (current === body) return { outcome: \"unchanged\", hookPath };\n fs.mkdirSync(path.dirname(hookPath), { recursive: true });\n fs.writeFileSync(hookPath, body, { mode: 0o755 });\n fs.chmodSync(hookPath, 0o755);\n return { outcome: current === undefined ? \"installed\" : \"updated\", hookPath };\n}\n"],"mappings":"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","names":["fs","path","fs","path"]}
@@ -1,4 +1,5 @@
1
1
  // src/allow.ts
2
+ import { compileGlobs, expandBraces } from "@titan-design/fix-proof";
2
3
  var EMPTY_ALLOW = { entries: [] };
3
4
  var AllowFileError = class extends Error {
4
5
  constructor(line, reason) {
@@ -10,25 +11,20 @@ var AllowFileError = class extends Error {
10
11
  };
11
12
  var ALLOWABLE = ["home-path", "aw-data-path"];
12
13
  var TASK_ID = /\b[A-Z]+-\d+\b/;
13
- function globToRegExp(glob) {
14
- let source = "";
15
- for (let i = 0; i < glob.length; i++) {
16
- const char = glob[i] ?? "";
17
- if (glob.startsWith("**/", i)) {
18
- source += "(?:.*/)?";
19
- i += 2;
20
- } else if (glob.startsWith("**", i)) {
21
- source += ".*";
22
- i += 1;
23
- } else if (char === "*") source += "[^/]*";
24
- else if (char === "?") source += "[^/]";
25
- else source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
26
- }
27
- return new RegExp(`^${source}$`);
28
- }
29
14
  function hasLiteralSegment(glob) {
30
15
  return glob.split("/").some((segment) => segment !== "" && !/[*?]/.test(segment));
31
16
  }
17
+ function compileEntry(glob, line) {
18
+ try {
19
+ if (!expandBraces(glob).every(hasLiteralSegment)) {
20
+ throw new AllowFileError(line, "glob must name at least one literal path segment");
21
+ }
22
+ return compileGlobs([glob]);
23
+ } catch (error) {
24
+ if (error instanceof AllowFileError) throw error;
25
+ throw new AllowFileError(line, error instanceof Error ? error.message : "glob cannot be compiled");
26
+ }
27
+ }
32
28
  function parseEntry(entry, line) {
33
29
  const fields = /^(\S+)\s+(\S+)\s+(.+)$/.exec(entry);
34
30
  if (!fields) throw new AllowFileError(line, "expected <glob> <rule-id> <reason>");
@@ -36,8 +32,7 @@ function parseEntry(entry, line) {
36
32
  if (rule === "private-term") throw new AllowFileError(line, "private-term is never allowable");
37
33
  if (!ALLOWABLE.includes(rule)) throw new AllowFileError(line, "unknown rule id");
38
34
  if (!TASK_ID.test(reason)) throw new AllowFileError(line, "reason must name a task id");
39
- if (!hasLiteralSegment(glob)) throw new AllowFileError(line, "glob must name at least one literal path segment");
40
- return { glob, rule, line, pattern: globToRegExp(glob) };
35
+ return { glob, rule, line, matches: compileEntry(glob, line) };
41
36
  }
42
37
  function parseAllow(text) {
43
38
  const entries = [];
@@ -50,7 +45,7 @@ function parseAllow(text) {
50
45
  }
51
46
  function isAllowed(allow, path, rule) {
52
47
  if (rule === "private-term") return false;
53
- return allow.entries.some((entry) => entry.rule === rule && entry.pattern.test(path));
48
+ return allow.entries.some((entry) => entry.rule === rule && entry.matches(path));
54
49
  }
55
50
 
56
51
  // src/terms.ts
@@ -85,7 +80,11 @@ function parseTerms(text) {
85
80
  if (entry === "" || entry.startsWith("#")) return;
86
81
  const pattern = compileTerm(entry, i + 1);
87
82
  if (pattern.test("")) throw new TermFileError(i + 1, "matches the empty string, so it would match every line");
88
- rules.push({ index: i + 1, matches: (candidate) => pattern.test(candidate) });
83
+ rules.push({
84
+ index: i + 1,
85
+ matches: (candidate) => pattern.test(candidate),
86
+ search: (candidate) => candidate.search(pattern)
87
+ });
89
88
  });
90
89
  return rules;
91
90
  }
@@ -245,15 +244,29 @@ function isPlaceholder(rawSegment) {
245
244
  if (/^<.*>$/.test(segment) || /^\{.*\}$/.test(segment) || segment.startsWith("$")) return true;
246
245
  return PLACEHOLDER_SET.has(segment.toLowerCase());
247
246
  }
248
- function matchesHomePath(text) {
247
+ function homePathIndex(text) {
249
248
  for (const match of text.matchAll(HOME_PATH)) {
250
- if (!isPlaceholder(match[1] ?? "")) return true;
249
+ if (!isPlaceholder(match[1] ?? "")) return match.index;
251
250
  }
252
- return false;
251
+ return -1;
252
+ }
253
+ function matchesHomePath(text) {
254
+ return homePathIndex(text) >= 0;
253
255
  }
254
256
  function matchesAwDataPath(text) {
255
257
  return AW_DATA_PATH.test(text);
256
258
  }
259
+ function locateRules(text, terms = []) {
260
+ const hits = [];
261
+ const home = homePathIndex(text);
262
+ if (home >= 0) hits.push({ rule: "home-path", offset: home });
263
+ const aw = AW_DATA_PATH.exec(text);
264
+ if (aw) hits.push({ rule: "aw-data-path", offset: aw.index });
265
+ for (const term of terms) {
266
+ if (term.matches(text)) hits.push({ rule: "private-term", termIndex: term.index, offset: Math.max(term.search?.(text) ?? 0, 0) });
267
+ }
268
+ return hits;
269
+ }
257
270
  function matchRules(text, terms = []) {
258
271
  const hits = [];
259
272
  if (matchesHomePath(text)) hits.push({ rule: "home-path" });
@@ -285,6 +298,7 @@ function scanFile(ctx, file, prefix) {
285
298
  function scanSource(ctx, source) {
286
299
  const prefix = source.sha === void 0 ? "" : `commit ${source.sha.slice(0, 7)} `;
287
300
  source.message?.forEach((text, i) => record(ctx, `${prefix}message:${i + 1}`, matchRules(text, ctx.terms)));
301
+ source.idents?.forEach(({ field, text }) => record(ctx, `${prefix}${field}`, matchRules(text, ctx.terms)));
288
302
  for (const file of source.files) scanFile(ctx, file, prefix);
289
303
  }
290
304
  function scan(sources, options = {}) {
@@ -320,6 +334,18 @@ function formatReport(findings, summary) {
320
334
  ];
321
335
  }
322
336
 
337
+ // src/text.ts
338
+ function scanText(text, options = {}) {
339
+ const findings = [];
340
+ text.split(/\r\n|\n|\r/).forEach((line, i) => {
341
+ for (const { rule, termIndex, offset } of locateRules(line, options.terms)) {
342
+ const location = `${i + 1}:${offset + 1}`;
343
+ findings.push(termIndex === void 0 ? { location, rule } : { location, rule, termIndex });
344
+ }
345
+ });
346
+ return findings;
347
+ }
348
+
323
349
  export {
324
350
  EMPTY_ALLOW,
325
351
  AllowFileError,
@@ -333,8 +359,11 @@ export {
333
359
  PLACEHOLDER_SEGMENTS,
334
360
  matchesHomePath,
335
361
  matchesAwDataPath,
362
+ locateRules,
336
363
  matchRules,
364
+ zeroCounts,
337
365
  scan,
338
- formatReport
366
+ formatReport,
367
+ scanText
339
368
  };
340
- //# sourceMappingURL=chunk-RK4WHDXR.js.map
369
+ //# sourceMappingURL=chunk-GJWQ3DYH.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/allow.ts","../src/terms.ts","../src/diff.ts","../src/rules.ts","../src/scan.ts","../src/report.ts","../src/text.ts"],"sourcesContent":["import { compileGlobs, expandBraces } from \"@titan-design/fix-proof\";\nimport type { RuleId } from \"./rules.js\";\n\nexport type AllowableRule = Exclude<RuleId, \"private-term\">;\n\nexport interface AllowEntry {\n readonly glob: string;\n readonly rule: AllowableRule;\n readonly line: number;\n readonly matches: (path: string) => boolean;\n}\n\nexport interface AllowList {\n readonly entries: readonly AllowEntry[];\n}\n\nexport const EMPTY_ALLOW: AllowList = { entries: [] };\n\n/** A malformed `.egress-allow`. Callers must fail the scan on it, never treat the file as empty. */\nexport class AllowFileError extends Error {\n constructor(\n readonly line: number,\n reason: string,\n ) {\n super(`.egress-allow line ${line}: ${reason}`);\n this.name = \"AllowFileError\";\n }\n}\n\nconst ALLOWABLE: readonly string[] = [\"home-path\", \"aw-data-path\"];\nconst TASK_ID = /\\b[A-Z]+-\\d+\\b/;\n\n// A glob made only of wildcard segments would allow a rule across the whole tree.\nfunction hasLiteralSegment(glob: string): boolean {\n return glob.split(\"/\").some((segment) => segment !== \"\" && !/[*?]/.test(segment));\n}\n\n// Every brace alternative must pass the guard: `{**,docs}/x.md` is fine, `{**,docs}/**` is not.\n// Expansion and compilation share one try so no raw RangeError or Error escapes parseAllow.\nfunction compileEntry(glob: string, line: number): (path: string) => boolean {\n try {\n if (!expandBraces(glob).every(hasLiteralSegment)) {\n throw new AllowFileError(line, \"glob must name at least one literal path segment\");\n }\n return compileGlobs([glob]);\n } catch (error) {\n if (error instanceof AllowFileError) throw error;\n throw new AllowFileError(line, error instanceof Error ? error.message : \"glob cannot be compiled\");\n }\n}\n\nfunction parseEntry(entry: string, line: number): AllowEntry {\n const fields = /^(\\S+)\\s+(\\S+)\\s+(.+)$/.exec(entry);\n if (!fields) throw new AllowFileError(line, \"expected <glob> <rule-id> <reason>\");\n const [, glob = \"\", rule = \"\", reason = \"\"] = fields;\n if (rule === \"private-term\") throw new AllowFileError(line, \"private-term is never allowable\");\n if (!ALLOWABLE.includes(rule)) throw new AllowFileError(line, \"unknown rule id\");\n if (!TASK_ID.test(reason)) throw new AllowFileError(line, \"reason must name a task id\");\n return { glob, rule: rule as AllowableRule, line, matches: compileEntry(glob, line) };\n}\n\nexport function parseAllow(text: string): AllowList {\n const entries: AllowEntry[] = [];\n text.split(/\\r?\\n/).forEach((raw, i) => {\n const entry = raw.trim();\n if (entry === \"\" || entry.startsWith(\"#\")) return;\n entries.push(parseEntry(entry, i + 1));\n });\n return { entries };\n}\n\nexport function isAllowed(allow: AllowList, path: string, rule: RuleId): boolean {\n if (rule === \"private-term\") return false;\n return allow.entries.some((entry) => entry.rule === rule && entry.matches(path));\n}\n","import type { TermRule } from \"./rules.js\";\n\n/** A term-file problem. The message names the line number only, never the term. */\nexport class TermFileError extends Error {\n constructor(\n readonly line: number,\n reason: string,\n ) {\n super(`private term list line ${line}: ${reason}`);\n this.name = \"TermFileError\";\n }\n}\n\nconst WORD_CHAR = String.raw`[\\p{L}\\p{N}_]`;\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\/]/g, \"\\\\$&\");\n}\n\nfunction compileRegex(source: string, line: number): RegExp {\n if (source === \"\") throw new TermFileError(line, \"empty regular expression\");\n try {\n return new RegExp(source, \"iu\");\n } catch {\n // The engine's message quotes the pattern, so it is dropped rather than wrapped.\n throw new TermFileError(line, \"invalid regular expression\");\n }\n}\n\nfunction compileTerm(entry: string, line: number): RegExp {\n if (entry.startsWith(\"re:\")) return compileRegex(entry.slice(3), line);\n return new RegExp(`(?<!${WORD_CHAR})${escapeRegExp(entry)}(?!${WORD_CHAR})`, \"iu\");\n}\n\n/**\n * Parses a private term list: one term per line, `#` comments, blank lines ignored, matched\n * case-insensitively on word boundaries; a `re:` line is a regex source compiled with `iu`.\n */\nexport function parseTerms(text: string): TermRule[] {\n const rules: TermRule[] = [];\n text\n .replace(/^\\uFEFF/, \"\")\n .split(/\\r?\\n/)\n .forEach((raw, i) => {\n const entry = raw.trim();\n if (entry === \"\" || entry.startsWith(\"#\")) return;\n const pattern = compileTerm(entry, i + 1);\n if (pattern.test(\"\")) throw new TermFileError(i + 1, \"matches the empty string, so it would match every line\");\n rules.push({\n index: i + 1,\n matches: (candidate) => pattern.test(candidate),\n search: (candidate) => candidate.search(pattern),\n });\n });\n return rules;\n}\n","export interface AddedLine {\n readonly line: number;\n readonly text: string;\n}\n\nexport interface DiffFile {\n readonly path: string;\n /** 1-based position in its diff, used in place of a path that itself trips a rule. */\n readonly ordinal: number;\n /** True for a new, renamed or copied file, whose path is itself new text. */\n readonly pathAdded: boolean;\n readonly binary: boolean;\n readonly lines: readonly AddedLine[];\n}\n\n/** A short named value scanned whole, such as a commit's author name or a pushed ref name. */\nexport interface IdentField {\n readonly field: string;\n readonly text: string;\n}\n\n/** One unit of scanned text: a diff, or a commit's message, idents and diff. */\nexport interface ScanSource {\n readonly sha?: string;\n readonly message?: readonly string[];\n readonly idents?: readonly IdentField[];\n readonly files: readonly DiffFile[];\n readonly binaryFiles: number;\n}\n\ninterface MutableFile {\n path: string;\n ordinal: number;\n pathAdded: boolean;\n binary: boolean;\n lines: AddedLine[];\n}\n\ninterface Hunk {\n columns: number;\n newLine: number;\n remainingNew: number;\n remainingOld: number[];\n}\n\nconst HUNK_HEADER = /^(@{2,}) ((?:-\\d+(?:,\\d+)? )+)\\+(\\d+)(?:,(\\d+))? \\1/;\nconst C_ESCAPES: Record<string, number> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };\n\n/** Undoes git's C-style path quoting (`core.quotePath`), octal UTF-8 bytes included. */\nexport function unquotePath(raw: string): string {\n if (raw.length < 2 || !raw.startsWith('\"') || !raw.endsWith('\"')) return raw;\n const body = raw.slice(1, -1);\n const bytes: number[] = [];\n for (let i = 0; i < body.length; i++) {\n const char = body[i] ?? \"\";\n const next = body[i + 1] ?? \"\";\n if (char !== \"\\\\\") bytes.push(...new TextEncoder().encode(char));\n else if (/[0-7]/.test(next)) {\n bytes.push(parseInt(body.slice(i + 1, i + 4), 8));\n i += 3;\n } else {\n bytes.push(C_ESCAPES[next] ?? next.charCodeAt(0));\n i += 1;\n }\n }\n return new TextDecoder().decode(new Uint8Array(bytes));\n}\n\nfunction stripSide(path: string): string {\n return path.replace(/^[ab]\\//, \"\");\n}\n\nfunction headerPath(header: string): string {\n const combined = /^diff --(?:cc|combined) (.+)$/.exec(header);\n if (combined) return unquotePath(combined[1] ?? \"\");\n const rest = header.slice(\"diff --git \".length);\n const quoted = / (\"(?:[^\"\\\\]|\\\\.)*\")$/.exec(rest);\n if (quoted) return stripSide(unquotePath(quoted[1] ?? \"\"));\n const half = (rest.length - 1) / 2;\n if (Number.isInteger(half) && rest.slice(2, half) === rest.slice(half + 3)) return rest.slice(2, half);\n return stripSide(rest.slice(rest.lastIndexOf(\" b/\") + 1));\n}\n\nfunction rangeCount(token: string): number {\n const count = token.split(\",\")[1];\n return count === undefined ? 1 : Number(count);\n}\n\nfunction parseHunkHeader(line: string): Hunk | undefined {\n const match = HUNK_HEADER.exec(line);\n if (!match) return undefined;\n const oldRanges = (match[2] ?? \"\").trim().split(\" \");\n return {\n columns: (match[1] ?? \"\").length - 1,\n newLine: Number(match[3]),\n remainingNew: match[4] === undefined ? 1 : Number(match[4]),\n remainingOld: oldRanges.map(rangeCount),\n };\n}\n\nfunction isHunkLine(hunk: Hunk, line: string): boolean {\n if (line.startsWith(\"\\\\\")) return true;\n return line.length >= hunk.columns && /^[ +-]*$/.test(line.slice(0, hunk.columns));\n}\n\n/** Applies one hunk body line; a `-` in any column means the line is not in the new file. */\nfunction consumeHunkLine(hunk: Hunk, line: string, file: MutableFile): void {\n if (line.startsWith(\"\\\\\")) return;\n const marks = [...line.slice(0, hunk.columns)];\n const removed = marks.includes(\"-\");\n const oldMark = removed ? \"-\" : \" \";\n hunk.remainingOld = hunk.remainingOld.map((n, i) => (marks[i] === oldMark ? n - 1 : n));\n if (removed) return;\n if (marks.includes(\"+\")) file.lines.push({ line: hunk.newLine, text: line.slice(hunk.columns) });\n hunk.newLine++;\n hunk.remainingNew--;\n}\n\nfunction hunkDone(hunk: Hunk): boolean {\n return hunk.remainingNew <= 0 && hunk.remainingOld.every((n) => n <= 0);\n}\n\nfunction applyHeaderLine(line: string, file: MutableFile): Hunk | undefined {\n if (file.binary) return undefined;\n const renamed = /^(?:rename|copy) to (.+)$/.exec(line);\n if (line.startsWith(\"new file mode \")) file.pathAdded = true;\n else if (renamed) {\n file.path = unquotePath(renamed[1] ?? \"\");\n file.pathAdded = true;\n } else if (line.startsWith(\"+++ \")) {\n const target = line.slice(4).replace(/\\t$/, \"\");\n if (target !== \"/dev/null\") file.path = stripSide(unquotePath(target));\n } else if (line.startsWith(\"Binary files \") || line === \"GIT binary patch\") file.binary = true;\n else if (line.startsWith(\"@@\")) return parseHunkHeader(line);\n return undefined;\n}\n\nfunction mergeInto(target: MutableFile, file: DiffFile): void {\n target.pathAdded ||= file.pathAdded;\n target.binary ||= file.binary;\n const seen = new Set(target.lines.map((l) => `${l.line}\\n${l.text}`));\n target.lines.push(...file.lines.filter((l) => !seen.has(`${l.line}\\n${l.text}`)));\n}\n\n/** Folds the per-parent diffs of one merge into one entry per path, so a line is reported once. */\nfunction mergeSamePaths(files: readonly DiffFile[]): DiffFile[] {\n const byPath = new Map<string, MutableFile>();\n for (const file of files) {\n const target = byPath.get(file.path);\n if (target) mergeInto(target, file);\n else byPath.set(file.path, { ...file, ordinal: byPath.size + 1, lines: [...file.lines] });\n }\n return [...byPath.values()];\n}\n\n/**\n * Parses `git diff -U0` or `git show -U0` patch text into the added lines of each file, with\n * new-file line numbers. Removed lines are dropped; binary files are kept for their path only.\n * A merge shown with `--diff-merges=separate` names a path once per parent; those fold into one.\n */\nexport function parseDiff(text: string): ScanSource {\n const files: MutableFile[] = [];\n let hunk: Hunk | undefined;\n for (const line of text.split(\"\\n\")) {\n const file = files.at(-1);\n if (hunk && file && isHunkLine(hunk, line)) {\n consumeHunkLine(hunk, line, file);\n if (hunkDone(hunk)) hunk = undefined;\n continue;\n }\n hunk = undefined;\n if (/^diff --(?:git|cc|combined) /.test(line)) {\n files.push({ path: headerPath(line), ordinal: files.length + 1, pathAdded: false, binary: false, lines: [] });\n } else if (file) hunk = applyHeaderLine(line, file);\n }\n const merged = mergeSamePaths(files);\n return { files: merged, binaryFiles: merged.filter((f) => f.binary).length };\n}\n\n/** Parses `git show -U0 --format=%B%x00 <sha>` output: the message, a NUL, then the patch. */\nexport function parseCommit(sha: string, text: string): ScanSource {\n const cut = text.indexOf(\"\\0\");\n if (cut < 0) throw new Error(`commit ${sha.slice(0, 7)}: no NUL after the message`);\n const message = text.slice(0, cut).replace(/\\n+$/, \"\").split(\"\\n\");\n return { ...parseDiff(text.slice(cut + 1)), sha, message };\n}\n","export type RuleId = \"home-path\" | \"aw-data-path\" | \"private-term\";\n\nexport const RULE_IDS: readonly RuleId[] = [\"home-path\", \"aw-data-path\", \"private-term\"];\n\n/** A compiled private term. It exposes its line number, never its text or pattern. */\nexport interface TermRule {\n readonly index: number;\n matches(text: string): boolean;\n /** Offset of the first match, or -1. Optional so a bare matcher still works; a hit then reports column 1. */\n search?(text: string): number;\n}\n\nexport interface RuleHit {\n readonly rule: RuleId;\n readonly termIndex?: number;\n}\n\n/** Home-directory segments that are placeholders, not people. Compared case-insensitively. */\nexport const PLACEHOLDER_SEGMENTS: readonly string[] = [\n \"Shared\",\n \"runner\",\n \"you\",\n \"your-name\",\n \"user\",\n \"username\",\n \"me\",\n \"example\",\n \"name\",\n \"x\",\n \"alice\",\n \"bob\",\n];\n\n// One path separator: slash, backslash, or the JSON-escaped form of either.\nconst SEP = String.raw`(?:\\\\\\\\|\\\\/|/|\\\\)`;\nconst SEGMENT = String.raw`([^/\\\\\\s\"'\\`),;\\]]+)`;\nconst USERS = \"[Uu][Ss][Ee][Rr][Ss]\";\n// A root must start a path; after `file://` any host may precede it.\nconst PATH_START = String.raw`(?:(?<![\\w.-])|(?<=file://[^/\\s\"'\\`]*))`;\nconst HOME_ROOT = String.raw`(?:${PATH_START}${SEP}(?:${USERS}|[Hh][Oo][Mm][Ee])|(?<!\\w)[A-Za-z]:${SEP}${USERS})`;\nconst HOME_PATH = new RegExp(`${HOME_ROOT}${SEP}${SEGMENT}`, \"g\");\n\nconst SPACE = String.raw`(?: |%20|\\\\ )`;\nconst AW_DATA_ROOTS = [`Library${SEP}Application${SPACE}Support`, String.raw`\\.local${SEP}share`, `AppData${SEP}Local`];\n// The single definition of the active-work data directory shapes; narrow it here and nowhere else.\nconst AW_DATA_PATH = new RegExp(`(?:${AW_DATA_ROOTS.join(\"|\")})${SEP}active-work(?!\\\\w)`, \"i\");\n\nconst PLACEHOLDER_SET = new Set(PLACEHOLDER_SEGMENTS.map((s) => s.toLowerCase()));\n\nfunction isPlaceholder(rawSegment: string): boolean {\n const segment = rawSegment.replace(/[.:]+$/, \"\");\n if (segment === \"\") return true;\n if (/^<.*>$/.test(segment) || /^\\{.*\\}$/.test(segment) || segment.startsWith(\"$\")) return true;\n return PLACEHOLDER_SET.has(segment.toLowerCase());\n}\n\n/** Offset of the first home path that is not a placeholder, or -1. */\nexport function homePathIndex(text: string): number {\n for (const match of text.matchAll(HOME_PATH)) {\n if (!isPlaceholder(match[1] ?? \"\")) return match.index;\n }\n return -1;\n}\n\nexport function matchesHomePath(text: string): boolean {\n return homePathIndex(text) >= 0;\n}\n\nexport function matchesAwDataPath(text: string): boolean {\n return AW_DATA_PATH.test(text);\n}\n\n/** Like `matchRules`, with the 0-based offset of each hit in `text`. */\nexport function locateRules(text: string, terms: readonly TermRule[] = []): (RuleHit & { readonly offset: number })[] {\n const hits: (RuleHit & { readonly offset: number })[] = [];\n const home = homePathIndex(text);\n if (home >= 0) hits.push({ rule: \"home-path\", offset: home });\n const aw = AW_DATA_PATH.exec(text);\n if (aw) hits.push({ rule: \"aw-data-path\", offset: aw.index });\n for (const term of terms) {\n if (term.matches(text)) hits.push({ rule: \"private-term\", termIndex: term.index, offset: Math.max(term.search?.(text) ?? 0, 0) });\n }\n return hits;\n}\n\nexport function matchRules(text: string, terms: readonly TermRule[] = []): RuleHit[] {\n const hits: RuleHit[] = [];\n if (matchesHomePath(text)) hits.push({ rule: \"home-path\" });\n if (matchesAwDataPath(text)) hits.push({ rule: \"aw-data-path\" });\n for (const term of terms) {\n if (term.matches(text)) hits.push({ rule: \"private-term\", termIndex: term.index });\n }\n return hits;\n}\n","import { EMPTY_ALLOW, isAllowed, type AllowList } from \"./allow.js\";\nimport type { DiffFile, ScanSource } from \"./diff.js\";\nimport { matchRules, type RuleHit, type RuleId, type TermRule } from \"./rules.js\";\n\n/** Where a rule fired. By construction no field carries the matched text, term or line. */\nexport interface Finding {\n readonly location: string;\n readonly rule: RuleId;\n readonly termIndex?: number;\n}\n\nexport type RuleCounts = Record<RuleId, number>;\n\nexport interface ScanOptions {\n readonly terms?: readonly TermRule[];\n readonly allow?: AllowList;\n}\n\nexport interface ScanResult {\n readonly findings: Finding[];\n readonly allowed: RuleCounts;\n readonly binaryFilesSkipped: number;\n}\n\ninterface Context {\n readonly terms: readonly TermRule[];\n readonly allow: AllowList;\n readonly findings: Finding[];\n readonly allowed: RuleCounts;\n}\n\nexport function zeroCounts(): RuleCounts {\n return { \"home-path\": 0, \"aw-data-path\": 0, \"private-term\": 0 };\n}\n\nfunction record(ctx: Context, location: string, hits: readonly RuleHit[], path?: string): void {\n for (const { rule, termIndex } of hits) {\n if (path !== undefined && isAllowed(ctx.allow, path, rule)) ctx.allowed[rule]++;\n else ctx.findings.push(termIndex === undefined ? { location, rule } : { location, rule, termIndex });\n }\n}\n\nfunction scanFile(ctx: Context, file: DiffFile, prefix: string): void {\n const pathHits = matchRules(file.path, ctx.terms);\n // A path that trips a rule would leak through the location itself, so it is replaced by its ordinal.\n const name = pathHits.length > 0 ? `file #${file.ordinal}` : file.path;\n if (file.pathAdded) record(ctx, `${prefix}${name} path`, pathHits, file.path);\n for (const { line, text } of file.lines) {\n record(ctx, `${prefix}${name}:${line}`, matchRules(text, ctx.terms), file.path);\n }\n}\n\nfunction scanSource(ctx: Context, source: ScanSource): void {\n const prefix = source.sha === undefined ? \"\" : `commit ${source.sha.slice(0, 7)} `;\n source.message?.forEach((text, i) => record(ctx, `${prefix}message:${i + 1}`, matchRules(text, ctx.terms)));\n source.idents?.forEach(({ field, text }) => record(ctx, `${prefix}${field}`, matchRules(text, ctx.terms)));\n for (const file of source.files) scanFile(ctx, file, prefix);\n}\n\n/** Scans added lines, added file paths, commit messages and idents. Messages and idents are never allowable. */\nexport function scan(sources: readonly ScanSource[], options: ScanOptions = {}): ScanResult {\n const ctx: Context = {\n terms: options.terms ?? [],\n allow: options.allow ?? EMPTY_ALLOW,\n findings: [],\n allowed: zeroCounts(),\n };\n for (const source of sources) scanSource(ctx, source);\n const binaryFilesSkipped = sources.reduce((sum, source) => sum + source.binaryFiles, 0);\n return { findings: ctx.findings, allowed: ctx.allowed, binaryFilesSkipped };\n}\n","import { RULE_IDS } from \"./rules.js\";\nimport { zeroCounts, type Finding, type RuleCounts } from \"./scan.js\";\n\nexport interface ReportSummary {\n readonly allowed: RuleCounts;\n readonly binaryFilesSkipped: number;\n readonly termsLoaded: boolean;\n}\n\nfunction formatFinding(finding: Finding): string {\n const term = finding.termIndex === undefined ? \"\" : ` #${finding.termIndex}`;\n return `${finding.location} ${finding.rule}${term}`;\n}\n\nfunction formatCounts(counts: RuleCounts, rules: readonly string[]): string {\n return rules.map((rule) => `${rule} ${counts[rule as keyof RuleCounts]}`).join(\", \");\n}\n\n/**\n * Formats findings as `<location> <rule>[ #<termIndex>]` lines plus a summary. It takes no\n * scanned text, so it cannot echo a matched line, path fragment or term.\n */\nexport function formatReport(findings: readonly Finding[], summary: ReportSummary): string[] {\n const found = zeroCounts();\n for (const finding of findings) found[finding.rule]++;\n const plural = findings.length === 1 ? \"finding\" : \"findings\";\n return [\n ...findings.map(formatFinding),\n `egress-scan: ${findings.length} ${plural} (${formatCounts(found, RULE_IDS)})`,\n `allowed: ${formatCounts(summary.allowed, [\"home-path\", \"aw-data-path\"])}`,\n `binary files skipped: ${summary.binaryFilesSkipped}`,\n `private term list: ${summary.termsLoaded ? \"loaded\" : \"not loaded, generic rules only\"}`,\n ];\n}\n","import { locateRules, type TermRule } from \"./rules.js\";\nimport type { Finding } from \"./scan.js\";\n\nexport interface TextOptions {\n readonly terms?: readonly TermRule[];\n}\n\n/** Scans free text and reports each hit as `line:col`; `\\r\\n`, `\\n` and `\\r` each count as one break. */\nexport function scanText(text: string, options: TextOptions = {}): Finding[] {\n const findings: Finding[] = [];\n text.split(/\\r\\n|\\n|\\r/).forEach((line, i) => {\n for (const { rule, termIndex, offset } of locateRules(line, options.terms)) {\n const location = `${i + 1}:${offset + 1}`;\n findings.push(termIndex === undefined ? { location, rule } : { location, rule, termIndex });\n }\n });\n return findings;\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -4,6 +4,8 @@ declare const RULE_IDS: readonly RuleId[];
4
4
  interface TermRule {
5
5
  readonly index: number;
6
6
  matches(text: string): boolean;
7
+ /** Offset of the first match, or -1. Optional so a bare matcher still works; a hit then reports column 1. */
8
+ search?(text: string): number;
7
9
  }
8
10
  interface RuleHit {
9
11
  readonly rule: RuleId;
@@ -13,6 +15,10 @@ interface RuleHit {
13
15
  declare const PLACEHOLDER_SEGMENTS: readonly string[];
14
16
  declare function matchesHomePath(text: string): boolean;
15
17
  declare function matchesAwDataPath(text: string): boolean;
18
+ /** Like `matchRules`, with the 0-based offset of each hit in `text`. */
19
+ declare function locateRules(text: string, terms?: readonly TermRule[]): (RuleHit & {
20
+ readonly offset: number;
21
+ })[];
16
22
  declare function matchRules(text: string, terms?: readonly TermRule[]): RuleHit[];
17
23
 
18
24
  interface AddedLine {
@@ -28,10 +34,16 @@ interface DiffFile {
28
34
  readonly binary: boolean;
29
35
  readonly lines: readonly AddedLine[];
30
36
  }
31
- /** One unit of scanned text: a diff, or a commit's message plus its diff. */
37
+ /** A short named value scanned whole, such as a commit's author name or a pushed ref name. */
38
+ interface IdentField {
39
+ readonly field: string;
40
+ readonly text: string;
41
+ }
42
+ /** One unit of scanned text: a diff, or a commit's message, idents and diff. */
32
43
  interface ScanSource {
33
44
  readonly sha?: string;
34
45
  readonly message?: readonly string[];
46
+ readonly idents?: readonly IdentField[];
35
47
  readonly files: readonly DiffFile[];
36
48
  readonly binaryFiles: number;
37
49
  }
@@ -49,7 +61,7 @@ interface AllowEntry {
49
61
  readonly glob: string;
50
62
  readonly rule: AllowableRule;
51
63
  readonly line: number;
52
- readonly pattern: RegExp;
64
+ readonly matches: (path: string) => boolean;
53
65
  }
54
66
  interface AllowList {
55
67
  readonly entries: readonly AllowEntry[];
@@ -90,7 +102,7 @@ interface ScanResult {
90
102
  readonly allowed: RuleCounts;
91
103
  readonly binaryFilesSkipped: number;
92
104
  }
93
- /** Scans added lines, added file paths and commit messages. Messages are never allowable. */
105
+ /** Scans added lines, added file paths, commit messages and idents. Messages and idents are never allowable. */
94
106
  declare function scan(sources: readonly ScanSource[], options?: ScanOptions): ScanResult;
95
107
 
96
108
  interface ReportSummary {
@@ -104,4 +116,10 @@ interface ReportSummary {
104
116
  */
105
117
  declare function formatReport(findings: readonly Finding[], summary: ReportSummary): string[];
106
118
 
107
- export { type AddedLine, type AllowEntry, AllowFileError, type AllowList, type AllowableRule, type DiffFile, EMPTY_ALLOW, type Finding, PLACEHOLDER_SEGMENTS, RULE_IDS, type ReportSummary, type RuleCounts, type RuleHit, type RuleId, type ScanOptions, type ScanResult, type ScanSource, TermFileError, type TermRule, formatReport, isAllowed, matchRules, matchesAwDataPath, matchesHomePath, parseAllow, parseCommit, parseDiff, parseTerms, scan };
119
+ interface TextOptions {
120
+ readonly terms?: readonly TermRule[];
121
+ }
122
+ /** Scans free text and reports each hit as `line:col`; `\r\n`, `\n` and `\r` each count as one break. */
123
+ declare function scanText(text: string, options?: TextOptions): Finding[];
124
+
125
+ export { type AddedLine, type AllowEntry, AllowFileError, type AllowList, type AllowableRule, type DiffFile, EMPTY_ALLOW, type Finding, type IdentField, PLACEHOLDER_SEGMENTS, RULE_IDS, type ReportSummary, type RuleCounts, type RuleHit, type RuleId, type ScanOptions, type ScanResult, type ScanSource, TermFileError, type TermRule, formatReport, isAllowed, locateRules, matchRules, matchesAwDataPath, matchesHomePath, parseAllow, parseCommit, parseDiff, parseTerms, scan, scanText };
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  TermFileError,
7
7
  formatReport,
8
8
  isAllowed,
9
+ locateRules,
9
10
  matchRules,
10
11
  matchesAwDataPath,
11
12
  matchesHomePath,
@@ -13,8 +14,9 @@ import {
13
14
  parseCommit,
14
15
  parseDiff,
15
16
  parseTerms,
16
- scan
17
- } from "./chunk-RK4WHDXR.js";
17
+ scan,
18
+ scanText
19
+ } from "./chunk-GJWQ3DYH.js";
18
20
  export {
19
21
  AllowFileError,
20
22
  EMPTY_ALLOW,
@@ -23,6 +25,7 @@ export {
23
25
  TermFileError,
24
26
  formatReport,
25
27
  isAllowed,
28
+ locateRules,
26
29
  matchRules,
27
30
  matchesAwDataPath,
28
31
  matchesHomePath,
@@ -30,6 +33,7 @@ export {
30
33
  parseCommit,
31
34
  parseDiff,
32
35
  parseTerms,
33
- scan
36
+ scan,
37
+ scanText
34
38
  };
35
39
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@titan-design/egress-scan",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "Scan git diff text for home paths, private-workspace paths and private terms, reporting location and rule id only",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,6 +34,9 @@
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  },
37
+ "dependencies": {
38
+ "@titan-design/fix-proof": "^0.2.0"
39
+ },
37
40
  "scripts": {
38
41
  "build": "tsup",
39
42
  "typecheck": "tsc --noEmit",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/allow.ts","../src/terms.ts","../src/diff.ts","../src/rules.ts","../src/scan.ts","../src/report.ts"],"sourcesContent":["import type { RuleId } from \"./rules.js\";\n\nexport type AllowableRule = Exclude<RuleId, \"private-term\">;\n\nexport interface AllowEntry {\n readonly glob: string;\n readonly rule: AllowableRule;\n readonly line: number;\n readonly pattern: RegExp;\n}\n\nexport interface AllowList {\n readonly entries: readonly AllowEntry[];\n}\n\nexport const EMPTY_ALLOW: AllowList = { entries: [] };\n\n/** A malformed `.egress-allow`. Callers must fail the scan on it, never treat the file as empty. */\nexport class AllowFileError extends Error {\n constructor(\n readonly line: number,\n reason: string,\n ) {\n super(`.egress-allow line ${line}: ${reason}`);\n this.name = \"AllowFileError\";\n }\n}\n\nconst ALLOWABLE: readonly string[] = [\"home-path\", \"aw-data-path\"];\nconst TASK_ID = /\\b[A-Z]+-\\d+\\b/;\n\n/** Anchored glob over repo-relative paths: `**` spans directories, `*` and `?` stay within one. */\nexport function globToRegExp(glob: string): RegExp {\n let source = \"\";\n for (let i = 0; i < glob.length; i++) {\n const char = glob[i] ?? \"\";\n if (glob.startsWith(\"**/\", i)) {\n source += \"(?:.*/)?\";\n i += 2;\n } else if (glob.startsWith(\"**\", i)) {\n source += \".*\";\n i += 1;\n } else if (char === \"*\") source += \"[^/]*\";\n else if (char === \"?\") source += \"[^/]\";\n else source += char.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$&\");\n }\n return new RegExp(`^${source}$`);\n}\n\n// A glob made only of wildcard segments would allow a rule across the whole tree.\nfunction hasLiteralSegment(glob: string): boolean {\n return glob.split(\"/\").some((segment) => segment !== \"\" && !/[*?]/.test(segment));\n}\n\nfunction parseEntry(entry: string, line: number): AllowEntry {\n const fields = /^(\\S+)\\s+(\\S+)\\s+(.+)$/.exec(entry);\n if (!fields) throw new AllowFileError(line, \"expected <glob> <rule-id> <reason>\");\n const [, glob = \"\", rule = \"\", reason = \"\"] = fields;\n if (rule === \"private-term\") throw new AllowFileError(line, \"private-term is never allowable\");\n if (!ALLOWABLE.includes(rule)) throw new AllowFileError(line, \"unknown rule id\");\n if (!TASK_ID.test(reason)) throw new AllowFileError(line, \"reason must name a task id\");\n if (!hasLiteralSegment(glob)) throw new AllowFileError(line, \"glob must name at least one literal path segment\");\n return { glob, rule: rule as AllowableRule, line, pattern: globToRegExp(glob) };\n}\n\nexport function parseAllow(text: string): AllowList {\n const entries: AllowEntry[] = [];\n text.split(/\\r?\\n/).forEach((raw, i) => {\n const entry = raw.trim();\n if (entry === \"\" || entry.startsWith(\"#\")) return;\n entries.push(parseEntry(entry, i + 1));\n });\n return { entries };\n}\n\nexport function isAllowed(allow: AllowList, path: string, rule: RuleId): boolean {\n if (rule === \"private-term\") return false;\n return allow.entries.some((entry) => entry.rule === rule && entry.pattern.test(path));\n}\n","import type { TermRule } from \"./rules.js\";\n\n/** A term-file problem. The message names the line number only, never the term. */\nexport class TermFileError extends Error {\n constructor(\n readonly line: number,\n reason: string,\n ) {\n super(`private term list line ${line}: ${reason}`);\n this.name = \"TermFileError\";\n }\n}\n\nconst WORD_CHAR = String.raw`[\\p{L}\\p{N}_]`;\n\nfunction escapeRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\/]/g, \"\\\\$&\");\n}\n\nfunction compileRegex(source: string, line: number): RegExp {\n if (source === \"\") throw new TermFileError(line, \"empty regular expression\");\n try {\n return new RegExp(source, \"iu\");\n } catch {\n // The engine's message quotes the pattern, so it is dropped rather than wrapped.\n throw new TermFileError(line, \"invalid regular expression\");\n }\n}\n\nfunction compileTerm(entry: string, line: number): RegExp {\n if (entry.startsWith(\"re:\")) return compileRegex(entry.slice(3), line);\n return new RegExp(`(?<!${WORD_CHAR})${escapeRegExp(entry)}(?!${WORD_CHAR})`, \"iu\");\n}\n\n/**\n * Parses a private term list: one term per line, `#` comments, blank lines ignored, matched\n * case-insensitively on word boundaries; a `re:` line is a regex source compiled with `iu`.\n */\nexport function parseTerms(text: string): TermRule[] {\n const rules: TermRule[] = [];\n text\n .replace(/^\\uFEFF/, \"\")\n .split(/\\r?\\n/)\n .forEach((raw, i) => {\n const entry = raw.trim();\n if (entry === \"\" || entry.startsWith(\"#\")) return;\n const pattern = compileTerm(entry, i + 1);\n if (pattern.test(\"\")) throw new TermFileError(i + 1, \"matches the empty string, so it would match every line\");\n rules.push({ index: i + 1, matches: (candidate) => pattern.test(candidate) });\n });\n return rules;\n}\n","export interface AddedLine {\n readonly line: number;\n readonly text: string;\n}\n\nexport interface DiffFile {\n readonly path: string;\n /** 1-based position in its diff, used in place of a path that itself trips a rule. */\n readonly ordinal: number;\n /** True for a new, renamed or copied file, whose path is itself new text. */\n readonly pathAdded: boolean;\n readonly binary: boolean;\n readonly lines: readonly AddedLine[];\n}\n\n/** One unit of scanned text: a diff, or a commit's message plus its diff. */\nexport interface ScanSource {\n readonly sha?: string;\n readonly message?: readonly string[];\n readonly files: readonly DiffFile[];\n readonly binaryFiles: number;\n}\n\ninterface MutableFile {\n path: string;\n ordinal: number;\n pathAdded: boolean;\n binary: boolean;\n lines: AddedLine[];\n}\n\ninterface Hunk {\n columns: number;\n newLine: number;\n remainingNew: number;\n remainingOld: number[];\n}\n\nconst HUNK_HEADER = /^(@{2,}) ((?:-\\d+(?:,\\d+)? )+)\\+(\\d+)(?:,(\\d+))? \\1/;\nconst C_ESCAPES: Record<string, number> = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13 };\n\n/** Undoes git's C-style path quoting (`core.quotePath`), octal UTF-8 bytes included. */\nexport function unquotePath(raw: string): string {\n if (raw.length < 2 || !raw.startsWith('\"') || !raw.endsWith('\"')) return raw;\n const body = raw.slice(1, -1);\n const bytes: number[] = [];\n for (let i = 0; i < body.length; i++) {\n const char = body[i] ?? \"\";\n const next = body[i + 1] ?? \"\";\n if (char !== \"\\\\\") bytes.push(...new TextEncoder().encode(char));\n else if (/[0-7]/.test(next)) {\n bytes.push(parseInt(body.slice(i + 1, i + 4), 8));\n i += 3;\n } else {\n bytes.push(C_ESCAPES[next] ?? next.charCodeAt(0));\n i += 1;\n }\n }\n return new TextDecoder().decode(new Uint8Array(bytes));\n}\n\nfunction stripSide(path: string): string {\n return path.replace(/^[ab]\\//, \"\");\n}\n\nfunction headerPath(header: string): string {\n const combined = /^diff --(?:cc|combined) (.+)$/.exec(header);\n if (combined) return unquotePath(combined[1] ?? \"\");\n const rest = header.slice(\"diff --git \".length);\n const quoted = / (\"(?:[^\"\\\\]|\\\\.)*\")$/.exec(rest);\n if (quoted) return stripSide(unquotePath(quoted[1] ?? \"\"));\n const half = (rest.length - 1) / 2;\n if (Number.isInteger(half) && rest.slice(2, half) === rest.slice(half + 3)) return rest.slice(2, half);\n return stripSide(rest.slice(rest.lastIndexOf(\" b/\") + 1));\n}\n\nfunction rangeCount(token: string): number {\n const count = token.split(\",\")[1];\n return count === undefined ? 1 : Number(count);\n}\n\nfunction parseHunkHeader(line: string): Hunk | undefined {\n const match = HUNK_HEADER.exec(line);\n if (!match) return undefined;\n const oldRanges = (match[2] ?? \"\").trim().split(\" \");\n return {\n columns: (match[1] ?? \"\").length - 1,\n newLine: Number(match[3]),\n remainingNew: match[4] === undefined ? 1 : Number(match[4]),\n remainingOld: oldRanges.map(rangeCount),\n };\n}\n\nfunction isHunkLine(hunk: Hunk, line: string): boolean {\n if (line.startsWith(\"\\\\\")) return true;\n return line.length >= hunk.columns && /^[ +-]*$/.test(line.slice(0, hunk.columns));\n}\n\n/** Applies one hunk body line; a `-` in any column means the line is not in the new file. */\nfunction consumeHunkLine(hunk: Hunk, line: string, file: MutableFile): void {\n if (line.startsWith(\"\\\\\")) return;\n const marks = [...line.slice(0, hunk.columns)];\n const removed = marks.includes(\"-\");\n const oldMark = removed ? \"-\" : \" \";\n hunk.remainingOld = hunk.remainingOld.map((n, i) => (marks[i] === oldMark ? n - 1 : n));\n if (removed) return;\n if (marks.includes(\"+\")) file.lines.push({ line: hunk.newLine, text: line.slice(hunk.columns) });\n hunk.newLine++;\n hunk.remainingNew--;\n}\n\nfunction hunkDone(hunk: Hunk): boolean {\n return hunk.remainingNew <= 0 && hunk.remainingOld.every((n) => n <= 0);\n}\n\nfunction applyHeaderLine(line: string, file: MutableFile): Hunk | undefined {\n if (file.binary) return undefined;\n const renamed = /^(?:rename|copy) to (.+)$/.exec(line);\n if (line.startsWith(\"new file mode \")) file.pathAdded = true;\n else if (renamed) {\n file.path = unquotePath(renamed[1] ?? \"\");\n file.pathAdded = true;\n } else if (line.startsWith(\"+++ \")) {\n const target = line.slice(4).replace(/\\t$/, \"\");\n if (target !== \"/dev/null\") file.path = stripSide(unquotePath(target));\n } else if (line.startsWith(\"Binary files \") || line === \"GIT binary patch\") file.binary = true;\n else if (line.startsWith(\"@@\")) return parseHunkHeader(line);\n return undefined;\n}\n\nfunction mergeInto(target: MutableFile, file: DiffFile): void {\n target.pathAdded ||= file.pathAdded;\n target.binary ||= file.binary;\n const seen = new Set(target.lines.map((l) => `${l.line}\\n${l.text}`));\n target.lines.push(...file.lines.filter((l) => !seen.has(`${l.line}\\n${l.text}`)));\n}\n\n/** Folds the per-parent diffs of one merge into one entry per path, so a line is reported once. */\nfunction mergeSamePaths(files: readonly DiffFile[]): DiffFile[] {\n const byPath = new Map<string, MutableFile>();\n for (const file of files) {\n const target = byPath.get(file.path);\n if (target) mergeInto(target, file);\n else byPath.set(file.path, { ...file, ordinal: byPath.size + 1, lines: [...file.lines] });\n }\n return [...byPath.values()];\n}\n\n/**\n * Parses `git diff -U0` or `git show -U0` patch text into the added lines of each file, with\n * new-file line numbers. Removed lines are dropped; binary files are kept for their path only.\n * A merge shown with `--diff-merges=separate` names a path once per parent; those fold into one.\n */\nexport function parseDiff(text: string): ScanSource {\n const files: MutableFile[] = [];\n let hunk: Hunk | undefined;\n for (const line of text.split(\"\\n\")) {\n const file = files.at(-1);\n if (hunk && file && isHunkLine(hunk, line)) {\n consumeHunkLine(hunk, line, file);\n if (hunkDone(hunk)) hunk = undefined;\n continue;\n }\n hunk = undefined;\n if (/^diff --(?:git|cc|combined) /.test(line)) {\n files.push({ path: headerPath(line), ordinal: files.length + 1, pathAdded: false, binary: false, lines: [] });\n } else if (file) hunk = applyHeaderLine(line, file);\n }\n const merged = mergeSamePaths(files);\n return { files: merged, binaryFiles: merged.filter((f) => f.binary).length };\n}\n\n/** Parses `git show -U0 --format=%B%x00 <sha>` output: the message, a NUL, then the patch. */\nexport function parseCommit(sha: string, text: string): ScanSource {\n const cut = text.indexOf(\"\\0\");\n if (cut < 0) throw new Error(`commit ${sha.slice(0, 7)}: no NUL after the message`);\n const message = text.slice(0, cut).replace(/\\n+$/, \"\").split(\"\\n\");\n return { ...parseDiff(text.slice(cut + 1)), sha, message };\n}\n","export type RuleId = \"home-path\" | \"aw-data-path\" | \"private-term\";\n\nexport const RULE_IDS: readonly RuleId[] = [\"home-path\", \"aw-data-path\", \"private-term\"];\n\n/** A compiled private term. It exposes its line number, never its text or pattern. */\nexport interface TermRule {\n readonly index: number;\n matches(text: string): boolean;\n}\n\nexport interface RuleHit {\n readonly rule: RuleId;\n readonly termIndex?: number;\n}\n\n/** Home-directory segments that are placeholders, not people. Compared case-insensitively. */\nexport const PLACEHOLDER_SEGMENTS: readonly string[] = [\n \"Shared\",\n \"runner\",\n \"you\",\n \"your-name\",\n \"user\",\n \"username\",\n \"me\",\n \"example\",\n \"name\",\n \"x\",\n \"alice\",\n \"bob\",\n];\n\n// One path separator: slash, backslash, or the JSON-escaped form of either.\nconst SEP = String.raw`(?:\\\\\\\\|\\\\/|/|\\\\)`;\nconst SEGMENT = String.raw`([^/\\\\\\s\"'\\`),;\\]]+)`;\nconst USERS = \"[Uu][Ss][Ee][Rr][Ss]\";\n// A root must start a path; after `file://` any host may precede it.\nconst PATH_START = String.raw`(?:(?<![\\w.-])|(?<=file://[^/\\s\"'\\`]*))`;\nconst HOME_ROOT = String.raw`(?:${PATH_START}${SEP}(?:${USERS}|[Hh][Oo][Mm][Ee])|(?<!\\w)[A-Za-z]:${SEP}${USERS})`;\nconst HOME_PATH = new RegExp(`${HOME_ROOT}${SEP}${SEGMENT}`, \"g\");\n\nconst SPACE = String.raw`(?: |%20|\\\\ )`;\nconst AW_DATA_ROOTS = [`Library${SEP}Application${SPACE}Support`, String.raw`\\.local${SEP}share`, `AppData${SEP}Local`];\n// The single definition of the active-work data directory shapes; narrow it here and nowhere else.\nconst AW_DATA_PATH = new RegExp(`(?:${AW_DATA_ROOTS.join(\"|\")})${SEP}active-work(?!\\\\w)`, \"i\");\n\nconst PLACEHOLDER_SET = new Set(PLACEHOLDER_SEGMENTS.map((s) => s.toLowerCase()));\n\nfunction isPlaceholder(rawSegment: string): boolean {\n const segment = rawSegment.replace(/[.:]+$/, \"\");\n if (segment === \"\") return true;\n if (/^<.*>$/.test(segment) || /^\\{.*\\}$/.test(segment) || segment.startsWith(\"$\")) return true;\n return PLACEHOLDER_SET.has(segment.toLowerCase());\n}\n\nexport function matchesHomePath(text: string): boolean {\n for (const match of text.matchAll(HOME_PATH)) {\n if (!isPlaceholder(match[1] ?? \"\")) return true;\n }\n return false;\n}\n\nexport function matchesAwDataPath(text: string): boolean {\n return AW_DATA_PATH.test(text);\n}\n\nexport function matchRules(text: string, terms: readonly TermRule[] = []): RuleHit[] {\n const hits: RuleHit[] = [];\n if (matchesHomePath(text)) hits.push({ rule: \"home-path\" });\n if (matchesAwDataPath(text)) hits.push({ rule: \"aw-data-path\" });\n for (const term of terms) {\n if (term.matches(text)) hits.push({ rule: \"private-term\", termIndex: term.index });\n }\n return hits;\n}\n","import { EMPTY_ALLOW, isAllowed, type AllowList } from \"./allow.js\";\nimport type { DiffFile, ScanSource } from \"./diff.js\";\nimport { matchRules, type RuleHit, type RuleId, type TermRule } from \"./rules.js\";\n\n/** Where a rule fired. By construction no field carries the matched text, term or line. */\nexport interface Finding {\n readonly location: string;\n readonly rule: RuleId;\n readonly termIndex?: number;\n}\n\nexport type RuleCounts = Record<RuleId, number>;\n\nexport interface ScanOptions {\n readonly terms?: readonly TermRule[];\n readonly allow?: AllowList;\n}\n\nexport interface ScanResult {\n readonly findings: Finding[];\n readonly allowed: RuleCounts;\n readonly binaryFilesSkipped: number;\n}\n\ninterface Context {\n readonly terms: readonly TermRule[];\n readonly allow: AllowList;\n readonly findings: Finding[];\n readonly allowed: RuleCounts;\n}\n\nexport function zeroCounts(): RuleCounts {\n return { \"home-path\": 0, \"aw-data-path\": 0, \"private-term\": 0 };\n}\n\nfunction record(ctx: Context, location: string, hits: readonly RuleHit[], path?: string): void {\n for (const { rule, termIndex } of hits) {\n if (path !== undefined && isAllowed(ctx.allow, path, rule)) ctx.allowed[rule]++;\n else ctx.findings.push(termIndex === undefined ? { location, rule } : { location, rule, termIndex });\n }\n}\n\nfunction scanFile(ctx: Context, file: DiffFile, prefix: string): void {\n const pathHits = matchRules(file.path, ctx.terms);\n // A path that trips a rule would leak through the location itself, so it is replaced by its ordinal.\n const name = pathHits.length > 0 ? `file #${file.ordinal}` : file.path;\n if (file.pathAdded) record(ctx, `${prefix}${name} path`, pathHits, file.path);\n for (const { line, text } of file.lines) {\n record(ctx, `${prefix}${name}:${line}`, matchRules(text, ctx.terms), file.path);\n }\n}\n\nfunction scanSource(ctx: Context, source: ScanSource): void {\n const prefix = source.sha === undefined ? \"\" : `commit ${source.sha.slice(0, 7)} `;\n source.message?.forEach((text, i) => record(ctx, `${prefix}message:${i + 1}`, matchRules(text, ctx.terms)));\n for (const file of source.files) scanFile(ctx, file, prefix);\n}\n\n/** Scans added lines, added file paths and commit messages. Messages are never allowable. */\nexport function scan(sources: readonly ScanSource[], options: ScanOptions = {}): ScanResult {\n const ctx: Context = {\n terms: options.terms ?? [],\n allow: options.allow ?? EMPTY_ALLOW,\n findings: [],\n allowed: zeroCounts(),\n };\n for (const source of sources) scanSource(ctx, source);\n const binaryFilesSkipped = sources.reduce((sum, source) => sum + source.binaryFiles, 0);\n return { findings: ctx.findings, allowed: ctx.allowed, binaryFilesSkipped };\n}\n","import { RULE_IDS } from \"./rules.js\";\nimport { zeroCounts, type Finding, type RuleCounts } from \"./scan.js\";\n\nexport interface ReportSummary {\n readonly allowed: RuleCounts;\n readonly binaryFilesSkipped: number;\n readonly termsLoaded: boolean;\n}\n\nfunction formatFinding(finding: Finding): string {\n const term = finding.termIndex === undefined ? \"\" : ` #${finding.termIndex}`;\n return `${finding.location} ${finding.rule}${term}`;\n}\n\nfunction formatCounts(counts: RuleCounts, rules: readonly string[]): string {\n return rules.map((rule) => `${rule} ${counts[rule as keyof RuleCounts]}`).join(\", \");\n}\n\n/**\n * Formats findings as `<location> <rule>[ #<termIndex>]` lines plus a summary. It takes no\n * scanned text, so it cannot echo a matched line, path fragment or term.\n */\nexport function formatReport(findings: readonly Finding[], summary: ReportSummary): string[] {\n const found = zeroCounts();\n for (const finding of findings) found[finding.rule]++;\n const plural = findings.length === 1 ? \"finding\" : \"findings\";\n return [\n ...findings.map(formatFinding),\n `egress-scan: ${findings.length} ${plural} (${formatCounts(found, RULE_IDS)})`,\n `allowed: ${formatCounts(summary.allowed, [\"home-path\", \"aw-data-path\"])}`,\n `binary files skipped: ${summary.binaryFilesSkipped}`,\n `private term list: ${summary.termsLoaded ? \"loaded\" : \"not loaded, generic rules only\"}`,\n ];\n}\n"],"mappings":"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","names":[]}