@titan-design/egress-scan 0.2.0 → 0.3.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 +8 -1
- package/dist/bin.js +24 -11
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-RK4WHDXR.js → chunk-YQBLQ32X.js} +2 -1
- package/dist/chunk-YQBLQ32X.js.map +1 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-RK4WHDXR.js.map +0 -1
package/README.md
CHANGED
|
@@ -27,12 +27,19 @@ notices and errors to stderr.
|
|
|
27
27
|
in turn (`--diff-merges=separate`), because git's combined diff ignores `--text`; a path
|
|
28
28
|
both diffs name is reported once. The message is read with `--encoding=UTF-8`, so
|
|
29
29
|
`i18n.logOutputEncoding` cannot re-encode it past the rules.
|
|
30
|
+
- **Idents and ref names.** Each scanned commit's raw author and committer name and email are
|
|
31
|
+
scanned too, and a finding names the field (`commit <sha> author.name`, `author.email`,
|
|
32
|
+
`committer.name`, `committer.email`). `pre-push` also scans the local and remote ref names on
|
|
33
|
+
each stdin line, so a branch or tag named after a private term is refused as
|
|
34
|
+
`push line <n> local ref` or `push line <n> remote ref`. A ref deletion is not scanned, so a
|
|
35
|
+
leaked ref can still be deleted. Idents and ref names are never allowable.
|
|
30
36
|
- **Binary files are scanned as text.** Every `git show` and `git diff` passes `--text`, so a
|
|
31
37
|
file git calls binary (one NUL byte is enough) still yields its lines, and the report's
|
|
32
38
|
`binary files skipped` stays 0; if git ever prints a file as binary anyway, the scan exits 2.
|
|
33
39
|
There is no opt-out, since an opt-out would be a bypass.
|
|
34
40
|
Matching is on the bytes decoded as UTF-8: a term written in UTF-16 or another encoding is
|
|
35
|
-
not matched.
|
|
41
|
+
not matched. **UTF-16 text is not scanned**: a UTF-16LE or UTF-16BE file passes even
|
|
42
|
+
with `--text`, because its NUL-interleaved bytes never decode to the term.
|
|
36
43
|
- **Size limit.** A commit whose patch text is over 128 MiB (`MAX_PATCH_BYTES`) exits 2 with
|
|
37
44
|
one line naming its short sha and the limit. `tree` has the same limit on the whole tree's
|
|
38
45
|
patch text and exits 2 with one line naming `tree` and the limit. GitHub itself refuses a file over 100 MiB.
|
package/dist/bin.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
parseDiff,
|
|
9
9
|
parseTerms,
|
|
10
10
|
scan
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-YQBLQ32X.js";
|
|
12
12
|
|
|
13
13
|
// src/bin.ts
|
|
14
14
|
import * as fs3 from "fs";
|
|
@@ -130,7 +130,8 @@ var PATCH_FLAGS = [
|
|
|
130
130
|
"--src-prefix=a/",
|
|
131
131
|
"--dst-prefix=b/"
|
|
132
132
|
];
|
|
133
|
-
var MESSAGE_FLAGS = ["--no-patch", "--encoding=UTF-8", "--format=%B"];
|
|
133
|
+
var MESSAGE_FLAGS = ["--no-patch", "--encoding=UTF-8", "--format=%an%x00%ae%x00%cn%x00%ce%x00%B"];
|
|
134
|
+
var IDENT_FIELDS = ["author.name", "author.email", "committer.name", "committer.email"];
|
|
134
135
|
var COMMIT_PATCH_FLAGS = ["--diff-merges=separate", "--format=", ...PATCH_FLAGS];
|
|
135
136
|
var MAX_BUFFER = 1024 * 1024 * 1024;
|
|
136
137
|
var END_OF_OPTIONS = "--end-of-options";
|
|
@@ -181,9 +182,18 @@ function parsePrePush(stdin) {
|
|
|
181
182
|
throw new ConfigError(`pre-push stdin line ${i + 1}: field ${field + 1} is not a full sha`);
|
|
182
183
|
}
|
|
183
184
|
}
|
|
184
|
-
return { localSha: fields[1] ?? "", remoteSha: fields[3] ?? "" };
|
|
185
|
+
return { localRef: fields[0] ?? "", localSha: fields[1] ?? "", remoteRef: fields[2] ?? "", remoteSha: fields[3] ?? "" };
|
|
185
186
|
});
|
|
186
187
|
}
|
|
188
|
+
function refSource(pushLines) {
|
|
189
|
+
const idents = pushLines.flatMap(
|
|
190
|
+
(line, i) => isZeroSha(line.localSha) ? [] : [
|
|
191
|
+
{ field: `push line ${i + 1} local ref`, text: line.localRef },
|
|
192
|
+
{ field: `push line ${i + 1} remote ref`, text: line.remoteRef }
|
|
193
|
+
]
|
|
194
|
+
);
|
|
195
|
+
return { files: [], binaryFiles: 0, idents };
|
|
196
|
+
}
|
|
187
197
|
function hasCommit(cwd, sha) {
|
|
188
198
|
return spawnSync("git", ["cat-file", "-e", END_OF_OPTIONS, `${sha}^{commit}`], { cwd }).status === 0;
|
|
189
199
|
}
|
|
@@ -213,11 +223,17 @@ function showCommit(cwd, sha, flags, maxBytes) {
|
|
|
213
223
|
if (isOverBuffer(result.error)) throw new PatchTooLargeError(sha, maxBytes);
|
|
214
224
|
return stdoutOf(result, "show");
|
|
215
225
|
}
|
|
226
|
+
function splitHeader(text) {
|
|
227
|
+
const parts = text.split("\0");
|
|
228
|
+
const idents = IDENT_FIELDS.map((field, i) => ({ field, text: parts[i] ?? "" }));
|
|
229
|
+
const message = parts.slice(IDENT_FIELDS.length).join("\0").replace(/\n+$/, "").split("\n");
|
|
230
|
+
return { idents, message };
|
|
231
|
+
}
|
|
216
232
|
function readCommit(cwd, sha, maxPatchBytes = MAX_PATCH_BYTES) {
|
|
217
233
|
requireRevision(sha, "commit");
|
|
218
|
-
const message = showCommit(cwd, sha, MESSAGE_FLAGS, maxPatchBytes)
|
|
234
|
+
const { idents, message } = splitHeader(showCommit(cwd, sha, MESSAGE_FLAGS, maxPatchBytes));
|
|
219
235
|
const patch = parseDiff(showCommit(cwd, sha, COMMIT_PATCH_FLAGS, maxPatchBytes));
|
|
220
|
-
return requireAllText({ ...patch, sha, message }, `commit ${sha.slice(0, 7)}`);
|
|
236
|
+
return requireAllText({ ...patch, sha, message, idents }, `commit ${sha.slice(0, 7)}`);
|
|
221
237
|
}
|
|
222
238
|
function readTree(cwd, maxPatchBytes = MAX_PATCH_BYTES) {
|
|
223
239
|
const emptyTree = git(cwd, ["hash-object", "-t", "tree", "--stdin"], "").trim();
|
|
@@ -265,12 +281,9 @@ function readCommits(root, shas, maxPatchBytes) {
|
|
|
265
281
|
return [...new Set(shas)].map((sha) => readCommit(root, sha, maxPatchBytes));
|
|
266
282
|
}
|
|
267
283
|
function prePushSources(root, remote, io) {
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
updates.flatMap((update) => commitsForUpdate(root, remote, update)),
|
|
272
|
-
io.maxPatchBytes
|
|
273
|
-
);
|
|
284
|
+
const pushLines = parsePrePush(io.readStdin());
|
|
285
|
+
const shas = pushLines.flatMap((line) => commitsForUpdate(root, remote, line));
|
|
286
|
+
return [refSource(pushLines), ...readCommits(root, shas, io.maxPatchBytes)];
|
|
274
287
|
}
|
|
275
288
|
function runScan(io, collect) {
|
|
276
289
|
const root = repoRoot(io.cwd);
|
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 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 refSource,\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 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\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 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\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.\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"]}
|
|
@@ -285,6 +285,7 @@ function scanFile(ctx, file, prefix) {
|
|
|
285
285
|
function scanSource(ctx, source) {
|
|
286
286
|
const prefix = source.sha === void 0 ? "" : `commit ${source.sha.slice(0, 7)} `;
|
|
287
287
|
source.message?.forEach((text, i) => record(ctx, `${prefix}message:${i + 1}`, matchRules(text, ctx.terms)));
|
|
288
|
+
source.idents?.forEach(({ field, text }) => record(ctx, `${prefix}${field}`, matchRules(text, ctx.terms)));
|
|
288
289
|
for (const file of source.files) scanFile(ctx, file, prefix);
|
|
289
290
|
}
|
|
290
291
|
function scan(sources, options = {}) {
|
|
@@ -337,4 +338,4 @@ export {
|
|
|
337
338
|
scan,
|
|
338
339
|
formatReport
|
|
339
340
|
};
|
|
340
|
-
//# sourceMappingURL=chunk-
|
|
341
|
+
//# sourceMappingURL=chunk-YQBLQ32X.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"],"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/** 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}\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 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"],"mappings":"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","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -28,10 +28,16 @@ interface DiffFile {
|
|
|
28
28
|
readonly binary: boolean;
|
|
29
29
|
readonly lines: readonly AddedLine[];
|
|
30
30
|
}
|
|
31
|
-
/**
|
|
31
|
+
/** A short named value scanned whole, such as a commit's author name or a pushed ref name. */
|
|
32
|
+
interface IdentField {
|
|
33
|
+
readonly field: string;
|
|
34
|
+
readonly text: string;
|
|
35
|
+
}
|
|
36
|
+
/** One unit of scanned text: a diff, or a commit's message, idents and diff. */
|
|
32
37
|
interface ScanSource {
|
|
33
38
|
readonly sha?: string;
|
|
34
39
|
readonly message?: readonly string[];
|
|
40
|
+
readonly idents?: readonly IdentField[];
|
|
35
41
|
readonly files: readonly DiffFile[];
|
|
36
42
|
readonly binaryFiles: number;
|
|
37
43
|
}
|
|
@@ -90,7 +96,7 @@ interface ScanResult {
|
|
|
90
96
|
readonly allowed: RuleCounts;
|
|
91
97
|
readonly binaryFilesSkipped: number;
|
|
92
98
|
}
|
|
93
|
-
/** Scans added lines, added file paths
|
|
99
|
+
/** Scans added lines, added file paths, commit messages and idents. Messages and idents are never allowable. */
|
|
94
100
|
declare function scan(sources: readonly ScanSource[], options?: ScanOptions): ScanResult;
|
|
95
101
|
|
|
96
102
|
interface ReportSummary {
|
|
@@ -104,4 +110,4 @@ interface ReportSummary {
|
|
|
104
110
|
*/
|
|
105
111
|
declare function formatReport(findings: readonly Finding[], summary: ReportSummary): string[];
|
|
106
112
|
|
|
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 };
|
|
113
|
+
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, matchRules, matchesAwDataPath, matchesHomePath, parseAllow, parseCommit, parseDiff, parseTerms, scan };
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -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":[]}
|