session-orchestrator 5.9.0 → 5.11.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +65 -2
- package/README.md +11 -9
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/docs/USER-GUIDE.md +2 -0
- package/docs/agent-authoring.md +2 -0
- package/docs/ci-setup.md +46 -1
- package/docs/events-schema.md +37 -6
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/owner-config-schema.md +18 -1
- package/docs/owner-privacy-patterns.md +85 -0
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +15 -2
- package/docs/session-config-reference.md +1 -1
- package/evals/instruction-ablation/run.mjs +11 -6
- package/hooks/_lib/hook-import-set.json +27 -2
- package/hooks/_lib/orchestrator-ignore.mjs +112 -0
- package/hooks/enforce-commands.mjs +9 -19
- package/hooks/enforce-scope.mjs +23 -42
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +8 -1
- package/hooks/on-session-start.mjs +7 -0
- package/hooks/post-bash-write-verify.mjs +54 -24
- package/hooks/pre-task-scope-disjoint.mjs +12 -0
- package/hooks/subagent-telemetry.mjs +8 -3
- package/hooks/wave-scope-commit-guard.mjs +4 -1
- package/package.json +2 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +28 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/gates/gate-full.mjs +6 -0
- package/scripts/lib/gates/gate-helpers.mjs +2 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/named-vault-resolver.mjs +1 -1
- package/scripts/lib/owner-yaml.mjs +33 -3
- package/scripts/lib/platform.mjs +70 -1
- package/scripts/lib/pre-push-remote-gate.mjs +107 -0
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/quality-gate.mjs +6 -1
- package/scripts/lib/redact-spans.mjs +8 -17
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/scope-gate.mjs +68 -5
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
- package/scripts/lib/vault-backfill/template.mjs +1 -1
- package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
- package/scripts/lib/vault-status/board-writer.mjs +15 -7
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/migrate-vault-paths.mjs +57 -60
- package/scripts/release.mjs +463 -48
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-consolidate.mjs +19 -4
- package/skills/claude-md-drift-check/SKILL.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +22 -9
- package/skills/npm-publish/SKILL.md +3 -3
- package/skills/release/SKILL.md +2 -2
- package/skills/repo-audit/SKILL.md +1 -1
- package/skills/session-end/metrics-collection.md +28 -4
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -0
- package/skills/wave-executor/references/wave-loop-review.md +26 -2
|
@@ -1,121 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* check-owner-leakage.mjs — Scan tracked files for owner-privacy leakage patterns.
|
|
4
|
-
* Add --include-untracked to also scan new files that Git does not ignore.
|
|
5
|
-
*
|
|
6
|
-
* Implements the #462 audit trail durable CI guard (#471).
|
|
7
|
-
* Canonicalization-before-matching refactor (issue #661): the historical
|
|
8
|
-
* form-by-form treadmill (a fresh regex per encoding — P1 bare-no-slash #631,
|
|
9
|
-
* P9 dash-encoded #634, …) is replaced by a single CANONICALIZATION step.
|
|
10
|
-
* `canonicalizeLine()` collapses every known textual encoding of a path
|
|
11
|
-
* (URL-percent, dash-as-separator, backslash, double-slash, `\uXXXX`/`%uXXXX`
|
|
12
|
-
* escapes, HTML/numeric entities, unicode homoglyph slashes/dashes, case)
|
|
13
|
-
* into ONE canonical slash-form. CP1 is matched against that canonical form;
|
|
14
|
-
* the four DOT-anchored host/IP rules (CP2, CP3, CP7, CP8) are matched against
|
|
15
|
-
* the raw line AND the canonical form (#1080). The remaining rules stay RAW by
|
|
16
|
-
* measurement, not oversight — see the `canon` comment in the line loop for why
|
|
17
|
-
* the slash-anchored (CP4, CP10) and slug-anchored (CP6) rules must not be
|
|
18
|
-
* canonicalized. A novel encoding of the same path normalizes to the same
|
|
19
|
-
* canonical string, so it is caught structurally — no new regex required. Err
|
|
20
|
-
* toward over-matching: this is a security guard, a false-positive is cheap, a
|
|
21
|
-
* false-negative ships a leak to the public mirror.
|
|
22
|
-
*
|
|
23
|
-
* Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]
|
|
24
|
-
*
|
|
25
|
-
* Forbidden patterns (canonical rules CP1–CP10):
|
|
26
|
-
* CP1 personal home path `/Users/bernhardg…` — matched on the CANONICAL form,
|
|
27
|
-
* so the slash-form (P1, #631), the dash-encoded projects-dir form
|
|
28
|
-
* (legacy P9, #634), URL-percent-encoded, backslash, and any future
|
|
29
|
-
* separator-encoding all collapse to one rule.
|
|
30
|
-
* CP2 private GitLab host `gitlab.gotzendorfer.at`
|
|
31
|
-
* CP3 private events domain `events.gotzendorfer.at`
|
|
32
|
-
* CP4 private package scope `@goetzendorfer/…`
|
|
33
|
-
* CP5 DEFAULT_GITLAB_HOST on line with 'gotzendorfer' OR as exported const
|
|
34
|
-
* CP6 private project slugs (see PRIVATE_SLUGS constant below). Two scopes:
|
|
35
|
-
* the tracked-file scan (runScan) uses the FULL 7-slug PRIVATE_SLUGS list;
|
|
36
|
-
* the in-process vault-namespace guard (isOwnerLeakySegment) uses the
|
|
37
|
-
* narrower CP6_INPROCESS_PATTERNS — PRIVATE_SLUGS minus VAULT_CLEAR_SLUGS,
|
|
38
|
-
* leaving only 2 retained slugs. See the VAULT_CLEAR_SLUGS carve-out
|
|
39
|
-
* (issue #59 owner decision 2026-07-18).
|
|
40
|
-
* CP7 catch-all `gotzendorfer.at` not matching an allowlisted exclusion
|
|
41
|
-
* CP8 full RFC1918 private dotted-quad (10.x.x.x / 192.168.x.x / 172.16-31.x.x)
|
|
42
|
-
* — internal IP leak. Placeholder `.x` forms and CIDR/range notation are NOT
|
|
43
|
-
* matched (only literal 4-octet IPs), so SSRF-range docs stay clean.
|
|
44
|
-
* CP10 `~/Projects/<PersonalName>/` — personal-name segment in a Projects path
|
|
45
|
-
* (name-denylist driven; see PERSONAL_NAMES constant below; issue #653)
|
|
46
|
-
* CP11 host-local confidential customer/repo names — matched from a NEVER-COMMITTED
|
|
47
|
-
* list referenced by owner.yaml `paths.confidential-names-file`
|
|
48
|
-
* (env SO_CONFIDENTIAL_NAMES_FILE; issue #728a). UNIQUE among CP-rules: a CP11
|
|
49
|
-
* violation REDACTS the matched name span from its reported lineContent, because
|
|
50
|
-
* this scanner runs in a PUBLIC GitHub-Actions mirror — printing the confidential
|
|
51
|
-
* name verbatim to the public CI log would be a worse leak than the one guarded.
|
|
52
|
-
*
|
|
53
|
-
* CP11 is INACTIVE unless a host-local names file is configured (the ~99% default),
|
|
54
|
-
* so public CI and unconfigured hosts see no behaviour change.
|
|
55
|
-
*
|
|
56
|
-
* Legacy P-numbering note: P1+P9 are now ONE canonical rule (CP1). The FAIL
|
|
57
|
-
* label still names the offending class for the audit trail.
|
|
58
|
-
*
|
|
59
|
-
* Exclusions (line-scoped, never whole-file):
|
|
60
|
-
* 1. Lines with office@gotzendorfer.at or security@gotzendorfer.at
|
|
61
|
-
* and no other gotzendorfer.at token
|
|
62
|
-
* 2. https://gotzendorfer.at[...] URLs in README.md and the 3 manifest files
|
|
63
|
-
* 3. Manifest author/email/url/websiteURL/privacyPolicyURL/termsOfServiceURL keys
|
|
64
|
-
* in .claude-plugin/plugin.json, .claude-plugin/marketplace.json, .codex-plugin/plugin.json
|
|
65
|
-
* (covered by exclusions 1 + 2 above; listed explicitly for audit trail)
|
|
66
|
-
* 4. .orchestrator/audits/** — never scanned (excluded in file enumeration)
|
|
67
|
-
* 5. tests/lib/events-default-url.test.mjs — ONLY the exact JSDoc contract line:
|
|
68
|
-
* " * - No literal `events.gotzendorfer.at` URL appears anywhere in scripts/ or hooks/."
|
|
69
|
-
* (a real string-literal events.gotzendorfer.at elsewhere in that file still FAILs)
|
|
70
|
-
* 6. tests/scripts/export-hw-learnings.test.mjs — exempt from P8 ONLY: the RFC1918
|
|
71
|
-
* IPs there are the redaction subject of the anonymizeString suite, not leaks.
|
|
72
|
-
* 7. The public-site URL in the three PUBLISHED site pages (site/index.html,
|
|
73
|
-
* site/impressum/index.html, site/datenschutz/index.html) — the domain is a
|
|
74
|
-
* legally mandated publication (Impressum, §5 ECG) plus the JSON-LD
|
|
75
|
-
* publisher/author identity and the rel="author" footer link. Like every
|
|
76
|
-
* exclusion above it is LINE-SCOPED and reached only via isAllowlisted(), which
|
|
77
|
-
* only CP3 and CP7 consult — CP1/CP2/CP4/CP5/CP6/CP8/CP10/CP11 still fail on
|
|
78
|
-
* these files, on the very same line. (#1076)
|
|
79
|
-
*
|
|
80
|
-
* Exit codes:
|
|
81
|
-
* 0 — all checks passed
|
|
82
|
-
* 1 — at least one failure (or usage error / unreadable root)
|
|
83
|
-
*/
|
|
84
2
|
|
|
85
|
-
import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
|
|
86
|
-
import { join, extname, relative, basename, sep, resolve } from 'node:path';
|
|
3
|
+
import { readFileSync, readdirSync, statSync, existsSync, realpathSync, lstatSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
|
4
|
+
import { join, extname, relative, basename, sep, resolve, dirname, isAbsolute } from 'node:path';
|
|
87
5
|
import { execFileSync } from 'node:child_process';
|
|
88
6
|
import { argv } from 'node:process';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
89
8
|
import { fileURLToPath } from 'node:url';
|
|
90
9
|
import { createRequire } from 'node:module';
|
|
91
|
-
// NOTE: the two host-local helper modules (../config/host-paths.mjs and
|
|
92
|
-
// ./confidential-names.mjs) are imported DYNAMICALLY inside
|
|
93
|
-
// getConfidentialNamePatterns(), NOT statically here. This scanner is a
|
|
94
|
-
// documented standalone single-file vendoring target (security.md § Owner-Privacy:
|
|
95
|
-
// "Reuse the same scanner") — the .husky/pre-commit E2E and any consumer that
|
|
96
|
-
// copies ONLY this file into a fresh tree would otherwise crash at module load
|
|
97
|
-
// with ERR_MODULE_NOT_FOUND, blocking clean commits. Dynamic import lets CP11 go
|
|
98
|
-
// inert (one WARN line, exit unchanged) when the helpers are absent while CP1–CP10
|
|
99
|
-
// run unchanged. That degrade is scoped to ERR_MODULE_NOT_FOUND ALONE (#1244) —
|
|
100
|
-
// every other CP11 load failure fails CLOSED; see getConfidentialNamePatterns().
|
|
101
10
|
|
|
102
|
-
// ---------------------------------------------------------------------------
|
|
103
|
-
// CLI / import-mode detection (#661)
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
//
|
|
106
|
-
// This module is BOTH a CLI script (run by validate-plugin + .husky/pre-commit)
|
|
107
|
-
// AND an importable library (the canonicalization helpers are unit-tested in
|
|
108
|
-
// isolation). When imported, the top-level scan + process.exit() must NOT run.
|
|
109
|
-
// `isMain` is true only when this file is the node entry point.
|
|
110
|
-
// REALPATH BOTH SIDES (#1244 / same class as the #1153 argv[1] main-guard finding).
|
|
111
|
-
// `fileURLToPath(import.meta.url)` is already canonicalized by Node's ESM loader,
|
|
112
|
-
// so a plain `resolve(argv[1])` comparison silently fails whenever the invocation
|
|
113
|
-
// path traverses a symlink — on macOS every `/tmp/...` path does (`/tmp` →
|
|
114
|
-
// `/private/tmp`). The failure mode is the worst one a security guard has: isMain
|
|
115
|
-
// stays false, runScan() never runs, and the process prints NOTHING and exits 0,
|
|
116
|
-
// i.e. a clean-looking pass that never scanned a byte. realpathSync both sides so
|
|
117
|
-
// the two spellings of the same file compare equal; if realpathSync throws (path
|
|
118
|
-
// gone, permission denied) fall back to the historical comparison.
|
|
119
11
|
function canonicalPath(p) {
|
|
120
12
|
try {
|
|
121
13
|
return realpathSync(p);
|
|
@@ -127,92 +19,147 @@ const isMain =
|
|
|
127
19
|
argv[1] !== undefined &&
|
|
128
20
|
canonicalPath(resolve(argv[1])) === canonicalPath(fileURLToPath(import.meta.url));
|
|
129
21
|
|
|
130
|
-
|
|
131
|
-
const pluginRoot = argv[2];
|
|
22
|
+
const pluginRoot = argv[2] ? canonicalPath(resolve(argv[2])) : argv[2];
|
|
132
23
|
const includeUntracked = argv.slice(3).includes('--include-untracked');
|
|
24
|
+
const requireOwnerPatterns = argv.slice(3).includes('--require-owner-patterns');
|
|
25
|
+
const packedIndex = argv.indexOf('--packed-files');
|
|
26
|
+
const packedInventoryPath = packedIndex >= 0 ? argv[packedIndex + 1] : null;
|
|
27
|
+
if (isMain && packedIndex >= 0 && (!packedInventoryPath || packedInventoryPath.startsWith('--'))) {
|
|
28
|
+
console.error('packed-files requires an inventory path'); process.exit(1);
|
|
29
|
+
}
|
|
133
30
|
if (isMain && !pluginRoot) {
|
|
134
31
|
console.error('Usage: check-owner-leakage.mjs <plugin-root> [--include-untracked]');
|
|
135
32
|
process.exit(1);
|
|
136
33
|
}
|
|
137
34
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
'
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
35
|
+
// Host policy stays outside source and package; this file remains standalone.
|
|
36
|
+
export const VAULT_CLEAR_SLUGS = new Set();
|
|
37
|
+
|
|
38
|
+
// Mirrors the zero-import private-config-dir resolver because vendored copies carry this file alone.
|
|
39
|
+
const POLICY_KEYS = ['usernamePrefixes', 'privateHosts', 'eventsHosts', 'privateDomains', 'packageScopes', 'privateSlugs', 'vaultClearSlugs', 'personalNames', 'publicEmails', 'publicUrls'];
|
|
40
|
+
const POLICY_LIMIT_BYTES = 65536; // At most 256 literals per class; revisit only with measured larger policies.
|
|
41
|
+
let ownerCache;
|
|
42
|
+
let compiledOwnerState;
|
|
43
|
+
let compiledOwnerRules;
|
|
44
|
+
function policySource(env = process.env) {
|
|
45
|
+
const own = (env.SO_CONFIG_HOME || '').trim();
|
|
46
|
+
const xdg = (env.XDG_CONFIG_HOME || '').trim();
|
|
47
|
+
const dir = own || (xdg ? join(xdg, 'session-orchestrator') : join(homedir(), '.config', 'session-orchestrator'));
|
|
48
|
+
return (env.SO_OWNER_PATTERNS_FILE || '').trim() || join(dir, 'owner-patterns.json');
|
|
49
|
+
}
|
|
50
|
+
function checkedHostFile(filename) {
|
|
51
|
+
if (!isAbsolute(filename)) throw new Error('unsafe');
|
|
52
|
+
// Canonical OS aliases (/tmp on macOS) are allowed above the private source parent.
|
|
53
|
+
let cursor = dirname(filename);
|
|
54
|
+
const parent = lstatSync(cursor);
|
|
55
|
+
if (!parent.isDirectory() || parent.isSymbolicLink() || (parent.mode & 0o022)) throw new Error('unsafe');
|
|
56
|
+
const canonicalParent = realpathSync(cursor);
|
|
57
|
+
// Reject repo-local data even if ignored; walk every ancestor for a .git marker.
|
|
58
|
+
cursor = canonicalParent;
|
|
59
|
+
while (true) {
|
|
60
|
+
if (existsSync(join(cursor, '.git'))) throw new Error('unsafe');
|
|
61
|
+
const next = dirname(cursor); if (next === cursor) break; cursor = next;
|
|
62
|
+
}
|
|
63
|
+
// Reject user-controlled intermediate symlinks; OS-owned /var and /tmp aliases are trusted.
|
|
64
|
+
cursor = dirname(filename);
|
|
65
|
+
while (cursor !== dirname(cursor)) {
|
|
66
|
+
const st = lstatSync(cursor);
|
|
67
|
+
if ((st.isSymbolicLink() && st.uid !== 0) || (st.uid !== 0 && (st.mode & 0o022))) throw new Error('unsafe');
|
|
68
|
+
cursor = dirname(cursor);
|
|
69
|
+
}
|
|
70
|
+
const st = lstatSync(filename);
|
|
71
|
+
if (!st.isFile() || st.isSymbolicLink() || (st.mode & 0o7777) !== 0o600 || st.nlink !== 1 || (typeof process.getuid === 'function' && st.uid !== process.getuid())) throw new Error('unsafe');
|
|
72
|
+
if (st.size > POLICY_LIMIT_BYTES) throw new Error('invalid');
|
|
73
|
+
const fd = openSync(filename, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
74
|
+
try {
|
|
75
|
+
const opened = fstatSync(fd);
|
|
76
|
+
if (!opened.isFile() || opened.ino !== st.ino || opened.dev !== st.dev || (opened.mode & 0o7777) !== 0o600 || opened.size > POLICY_LIMIT_BYTES) throw new Error('unsafe');
|
|
77
|
+
const raw = readFileSync(fd, 'utf8');
|
|
78
|
+
if (Buffer.byteLength(raw) > POLICY_LIMIT_BYTES) throw new Error('invalid');
|
|
79
|
+
return raw;
|
|
80
|
+
} finally { closeSync(fd); }
|
|
81
|
+
}
|
|
82
|
+
export function inspectOwnerPatterns({ filePath, env = process.env, refresh = false } = {}) {
|
|
83
|
+
const filename = filePath ?? policySource(env);
|
|
84
|
+
if (!refresh && ownerCache?.filename === filename) return ownerCache.result;
|
|
85
|
+
let result;
|
|
86
|
+
try {
|
|
87
|
+
const policy = JSON.parse(checkedHostFile(filename));
|
|
88
|
+
if (!policy || Array.isArray(policy) || policy.version !== 1 || Object.keys(policy).some((key) => key !== 'version' && !POLICY_KEYS.includes(key))) throw new Error('invalid');
|
|
89
|
+
for (const key of POLICY_KEYS) {
|
|
90
|
+
if (!Object.hasOwn(policy, key)) policy[key] = [];
|
|
91
|
+
if (!Array.isArray(policy[key]) || policy[key].length > 256 || policy[key].some((v) => typeof v !== 'string' || !v.trim() || v.length > 256 || v !== v.trim() || [...v].some((c) => c.charCodeAt(0) < 32))) throw new Error('invalid');
|
|
92
|
+
}
|
|
93
|
+
if (policy.vaultClearSlugs.some((slug) => !policy.privateSlugs.some((s) => s.toLowerCase() === slug.toLowerCase()))) throw new Error('invalid');
|
|
94
|
+
if (!POLICY_KEYS.slice(0, 6).concat('personalNames').some((key) => policy[key].length)) throw new Error('invalid');
|
|
95
|
+
result = { status: 'ok', policy };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
result = { status: err.code === 'ENOENT' ? 'missing' : err.message === 'unsafe' || err.code === 'ELOOP' ? 'unsafe' : 'invalid', reason: err.code ?? (err.message === 'unsafe' ? 'unsafe-source' : 'invalid-source') };
|
|
98
|
+
}
|
|
99
|
+
ownerCache = {filename, result};
|
|
100
|
+
VAULT_CLEAR_SLUGS.clear();
|
|
101
|
+
for (const slug of result.policy?.vaultClearSlugs ?? []) VAULT_CLEAR_SLUGS.add(slug.toLowerCase());
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
function countMatches(re, value) { return (value.match(re) || []).length; }
|
|
172
105
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
106
|
+
function literalRegex(values, flags = '', prefix = '', suffix = '') {
|
|
107
|
+
return new RegExp(values.length ? prefix + '(?:' + values.map(escapeRegex).join('|') + ')' + suffix : '(?!)', flags);
|
|
108
|
+
}
|
|
109
|
+
function currentOwnerRules() {
|
|
110
|
+
const state = inspectOwnerPatterns();
|
|
111
|
+
if (compiledOwnerState === state) return compiledOwnerRules;
|
|
112
|
+
const policy = state.policy ?? Object.fromEntries(POLICY_KEYS.map((key) => [key, []]));
|
|
113
|
+
const insensitiveToken = (token) => [...token].map((c) => /[a-z]/i.test(c) ? '[' + c.toLowerCase() + c.toUpperCase() + ']' : escapeRegex(c)).join('');
|
|
114
|
+
const prefix = policy.usernamePrefixes.map(insensitiveToken).join('|') || '(?!)';
|
|
115
|
+
compiledOwnerState = state;
|
|
116
|
+
compiledOwnerRules = {
|
|
117
|
+
...state, policy,
|
|
118
|
+
CP1_CANON: new RegExp('/Users/(?:' + prefix + ')[a-z.]*(/|\\b)'),
|
|
119
|
+
CP1_BARE: new RegExp('^(?:' + prefix + ')[a-z]*$'),
|
|
120
|
+
SCANNER_REGEX_QUOTE_BLANK_G: new RegExp('(Users.)(?:' + prefix + ')(\\[)', 'g'),
|
|
121
|
+
CP2: literalRegex(policy.privateHosts, '', '\\b', '\\b'),
|
|
122
|
+
CP3: literalRegex(policy.eventsHosts, '', '\\b', '\\b'),
|
|
123
|
+
CP3_G: literalRegex(policy.eventsHosts, 'g', '\\b', '\\b'),
|
|
124
|
+
CP4: literalRegex(policy.packageScopes, '', '@', '/[A-Za-z0-9*_-]+'),
|
|
125
|
+
CP5_WITH_GOTZ: /DEFAULT_GITLAB_HOST/,
|
|
126
|
+
CP5_EXPORT: /\bexport\b.*\bconst\b.*\bDEFAULT_GITLAB_HOST\b/,
|
|
127
|
+
CP6_PATTERNS: policy.privateSlugs.map((slug) => literalRegex([slug], 'i', '\\b', '\\b')),
|
|
128
|
+
CP6_INPROCESS_PATTERNS: policy.privateSlugs.filter((slug) => !VAULT_CLEAR_SLUGS.has(slug.toLowerCase())).map((slug) => literalRegex([slug], 'i', '\\b', '\\b')),
|
|
129
|
+
CP7: literalRegex(policy.privateDomains), CP7_G: literalRegex(policy.privateDomains, 'g'),
|
|
130
|
+
CP10_PATTERNS: policy.personalNames.map((name) => literalRegex([name], '', '(?:~|/Users/[^/]+|/home/[^/]+)/Projects/', '(\\/|\\b)')),
|
|
131
|
+
redactionPatterns: POLICY_KEYS.filter((key) => !key.startsWith('public')).flatMap((key) => policy[key].map((value) => literalRegex([value], 'i'))),
|
|
132
|
+
};
|
|
133
|
+
return compiledOwnerRules;
|
|
134
|
+
}
|
|
135
|
+
function getPackedFiles() {
|
|
136
|
+
// npm's JSON inventory is bounded; filenames alone never constitute a content scan.
|
|
137
|
+
const inventoryFd = openSync(packedInventoryPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
138
|
+
let raw;
|
|
139
|
+
try {
|
|
140
|
+
const stat = fstatSync(inventoryFd);
|
|
141
|
+
if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error('invalid-inventory');
|
|
142
|
+
raw = readFileSync(inventoryFd, 'utf8');
|
|
143
|
+
if (Buffer.byteLength(raw) > 16 * 1024 * 1024) throw new Error('inventory-too-large');
|
|
144
|
+
} finally { closeSync(inventoryFd); }
|
|
145
|
+
const inventory = JSON.parse(raw);
|
|
146
|
+
const files = Array.isArray(inventory) && inventory.length === 1 ? inventory[0]?.files : null;
|
|
147
|
+
if (!Array.isArray(files) || !files.length || files.length > 20000) throw new Error('invalid-inventory');
|
|
148
|
+
const root = pluginRoot;
|
|
149
|
+
return [...new Set(files.map((entry) => {
|
|
150
|
+
const name = entry?.path;
|
|
151
|
+
if (typeof name !== 'string' || !name || name.length > 4096 || isAbsolute(name) || name.split('/').some((part) => part === '..' || part === '.' || !part) || (name.includes('\\') || [...name].some((c) => c.charCodeAt(0) < 32))) throw new Error('unsafe-inventory');
|
|
152
|
+
let current = root;
|
|
153
|
+
for (const part of name.split('/')) { current = join(current, part); if (lstatSync(current).isSymbolicLink()) throw new Error('unsafe-inventory'); }
|
|
154
|
+
if (!lstatSync(current).isFile()) throw new Error('invalid-inventory-entry');
|
|
155
|
+
return current;
|
|
156
|
+
}))];
|
|
157
|
+
}
|
|
198
158
|
|
|
199
|
-
/** Unicode homoglyph / variant SLASH code points that should canonicalize to '/'. */
|
|
200
159
|
const HOMOGLYPH_SLASHES = /[⁄∕/⧸╱]/g; // ⁄ ∕ / ⧸ ╱
|
|
201
|
-
/** Unicode homoglyph / variant DASH code points that should canonicalize to '-'. */
|
|
202
160
|
const HOMOGLYPH_DASHES =
|
|
203
161
|
/[‐‑‒–—―−﹘﹣-]/g; // ‐ ‑ ‒ – — ― − ﹘ ﹣ -
|
|
204
162
|
|
|
205
|
-
/**
|
|
206
|
-
* Zero-width / format / invisible code points that a leak can splice INTO the
|
|
207
|
-
* username (`/Users/bern<U+200B>hardg`) to evade a contiguous-literal match.
|
|
208
|
-
* Stripped from the canonical form before matching (Finding 3). Over-stripping
|
|
209
|
-
* is safe for a security guard — these glyphs never legitimately appear inside a
|
|
210
|
-
* filesystem path segment. Code points (escaped to keep the source ASCII-clean
|
|
211
|
-
* and lint-quiet): tab U+0009, soft-hyphen U+00AD, ZWSP U+200B, ZWNJ U+200C,
|
|
212
|
-
* ZWJ U+200D, word-joiner U+2060, BOM/ZWNBSP U+FEFF. Built from an explicit
|
|
213
|
-
* code-point list via a non-character-class alternation so the combining ZWJ
|
|
214
|
-
* does not trip no-misleading-character-class.
|
|
215
|
-
*/
|
|
216
163
|
const ZERO_WIDTH_FORMAT = new RegExp(
|
|
217
164
|
'(?:' +
|
|
218
165
|
['\\u0009', '\\u00ad', '\\u200b', '\\u200c', '\\u200d', '\\u2060', '\\ufeff'].join('|') +
|
|
@@ -220,41 +167,16 @@ const ZERO_WIDTH_FORMAT = new RegExp(
|
|
|
220
167
|
'gu',
|
|
221
168
|
);
|
|
222
169
|
|
|
223
|
-
/**
|
|
224
|
-
* Decode the printable-ASCII range of a hex code point, or null when out of
|
|
225
|
-
* range. Used by the percent / unicode-escape decoders so the LETTERS of
|
|
226
|
-
* `Users`/`bernhardg` (e.g. `%55` → `U`, `%62` → `b`) are decoded, not only the
|
|
227
|
-
* separators (Finding 4). Over-decoding into the full printable-ASCII range is
|
|
228
|
-
* intentional and safe for a security guard. CASE IS PRESERVED because the hex
|
|
229
|
-
* value itself encodes the case (`%55`→`U`, `%75`→`u`).
|
|
230
|
-
* @param {number} cp
|
|
231
|
-
* @returns {string|null}
|
|
232
|
-
*/
|
|
233
170
|
function printableAsciiFromCodePoint(cp) {
|
|
234
171
|
return cp >= 0x20 && cp <= 0x7e ? String.fromCharCode(cp) : null;
|
|
235
172
|
}
|
|
236
173
|
|
|
237
|
-
/**
|
|
238
|
-
* Decode URL-percent escapes back to their ASCII characters, WITHOUT touching
|
|
239
|
-
* unrelated literal `%`s (decodeURIComponent would throw on a stray `%`). The
|
|
240
|
-
* decode targets the WHOLE printable-ASCII range — both separators (`%2F` → /)
|
|
241
|
-
* AND the alphanumerics of `Users`/`bernhardg` (`%55` → U, #Finding 4) — and the
|
|
242
|
-
* `%uXXXX` JS/JSON-style percent-unicode escape. Double-encoding (`%252F`) is
|
|
243
|
-
* handled by the fixpoint loop in canonicalizeLine (a single decode pass turns
|
|
244
|
-
* `%252F` → `%2F`; the next pass turns `%2F` → `/`). Only the small set of
|
|
245
|
-
* always-relevant separators is hard-coded; everything else in printable-ASCII
|
|
246
|
-
* is decoded generically.
|
|
247
|
-
* @param {string} s
|
|
248
|
-
* @returns {string}
|
|
249
|
-
*/
|
|
250
174
|
function decodePercent(s) {
|
|
251
|
-
// `%uXXXX` (JS/JSON-style percent-unicode) — BMP code point in printable ASCII.
|
|
252
175
|
let out = s.replace(/%u([0-9a-fA-F]{4})/g, (_m, hex) => {
|
|
253
176
|
const cp = parseInt(hex, 16);
|
|
254
177
|
const ch = printableAsciiFromCodePoint(cp);
|
|
255
178
|
return ch === null ? _m : ch;
|
|
256
179
|
});
|
|
257
|
-
// `%XX` — any printable-ASCII byte (separators AND letters/digits).
|
|
258
180
|
out = out.replace(/%([0-9a-fA-F]{2})/g, (_m, hex) => {
|
|
259
181
|
const cp = parseInt(hex, 16);
|
|
260
182
|
const ch = printableAsciiFromCodePoint(cp);
|
|
@@ -263,13 +185,6 @@ function decodePercent(s) {
|
|
|
263
185
|
return out;
|
|
264
186
|
}
|
|
265
187
|
|
|
266
|
-
/**
|
|
267
|
-
* Decode JS/JSON `\uXXXX` escapes back to their ASCII characters — separators
|
|
268
|
-
* AND the alphanumerics of `Users`/`bernhardg` (Finding 4). Out-of-range code
|
|
269
|
-
* points are left intact.
|
|
270
|
-
* @param {string} s
|
|
271
|
-
* @returns {string}
|
|
272
|
-
*/
|
|
273
188
|
function decodeUnicodeEscapes(s) {
|
|
274
189
|
return s.replace(/\\u([0-9a-fA-F]{4})/g, (_m, hex) => {
|
|
275
190
|
const cp = parseInt(hex, 16);
|
|
@@ -278,292 +193,49 @@ function decodeUnicodeEscapes(s) {
|
|
|
278
193
|
});
|
|
279
194
|
}
|
|
280
195
|
|
|
281
|
-
/**
|
|
282
|
-
* Decode HTML named + numeric character references. Named entities cover the
|
|
283
|
-
* path separators; numeric entities (`U`, `U`) decode the WHOLE
|
|
284
|
-
* printable-ASCII range so the LETTERS of `Users`/`bernhardg` are decoded, not
|
|
285
|
-
* only the separators (Finding 4). Out-of-range code points are left intact.
|
|
286
|
-
* @param {string} s
|
|
287
|
-
* @returns {string}
|
|
288
|
-
*/
|
|
289
196
|
function decodeHtmlEntities(s) {
|
|
290
197
|
return s
|
|
291
198
|
.replace(///gi, '/')
|
|
292
199
|
.replace(/./gi, '.')
|
|
293
200
|
.replace(/&(?:dash|hyphen);/gi, '-')
|
|
294
|
-
// numeric hex entity — any printable-ASCII code point
|
|
295
201
|
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) => {
|
|
296
202
|
const ch = printableAsciiFromCodePoint(parseInt(hex, 16));
|
|
297
203
|
return ch === null ? _m : ch;
|
|
298
204
|
})
|
|
299
|
-
// numeric decimal entity — any printable-ASCII code point
|
|
300
205
|
.replace(/&#(\d+);/g, (_m, dec) => {
|
|
301
206
|
const ch = printableAsciiFromCodePoint(parseInt(dec, 10));
|
|
302
207
|
return ch === null ? _m : ch;
|
|
303
208
|
});
|
|
304
209
|
}
|
|
305
210
|
|
|
306
|
-
|
|
307
|
-
* Canonicalize one line of text into a single slash-form string suitable for
|
|
308
|
-
* separator-agnostic path matching. CASE IS PRESERVED — the owner-path rule
|
|
309
|
-
* (CP1) is anchored on the case-sensitive `/Users/bernhardg` literal, so a
|
|
310
|
-
* deliberately-lowercased `/users/...` source literal and an uppercase-letter
|
|
311
|
-
* username near-miss (`/Users/bernhardgXfoo`) both stay distinguishable after
|
|
312
|
-
* canonicalization. Only SEPARATORS / escapes are normalized, never letter case.
|
|
313
|
-
* Public for unit-testing the decode steps.
|
|
314
|
-
* @param {string} line
|
|
315
|
-
* @returns {string} canonical form (case preserved)
|
|
316
|
-
*/
|
|
317
|
-
export function canonicalizeLine(line) {
|
|
211
|
+
export function canonicalizeLine(line, { preserveDashes = false } = {}) {
|
|
318
212
|
let s = String(line);
|
|
319
|
-
// 0. strip zero-width / format / invisible chars (and tab) spliced into a
|
|
320
|
-
// path segment to break a contiguous-literal match (Finding 3). Done first
|
|
321
|
-
// AND inside the fixpoint loop so a glyph wedged between two ENCODED chars
|
|
322
|
-
// (e.g. `%2<U+200B>F`) also vanishes once its neighbours decode.
|
|
323
213
|
s = s.replace(ZERO_WIDTH_FORMAT, '');
|
|
324
|
-
// 1–3. decode every textual encoding to a FIXPOINT (Finding 5). A single pass
|
|
325
|
-
// only peels one encoding layer; a nested encoding (one decoder's output
|
|
326
|
-
// feeding another — `%252F` → `%2F` → `/`, or an entity whose decoded form
|
|
327
|
-
// is itself a percent-escape) needs the loop. The decoders are monotone
|
|
328
|
-
// (each only ever shrinks/normalizes), so the loop converges; the bound is
|
|
329
|
-
// a safety valve against any pathological non-converging input.
|
|
330
214
|
for (let i = 0; i < 12; i++) {
|
|
331
215
|
const before = s;
|
|
332
|
-
// strip format chars exposed by the previous round's decoding
|
|
333
216
|
s = s.replace(ZERO_WIDTH_FORMAT, '');
|
|
334
|
-
// percent escapes (separators AND letters, plus %uXXXX)
|
|
335
217
|
s = decodePercent(s);
|
|
336
|
-
// JS/JSON unicode escapes
|
|
337
218
|
s = decodeUnicodeEscapes(s);
|
|
338
|
-
// HTML entities (named + numeric, separators AND letters)
|
|
339
219
|
s = decodeHtmlEntities(s);
|
|
340
220
|
if (s === before) break; // fixpoint reached
|
|
341
221
|
}
|
|
342
|
-
// 4. unicode homoglyph separators → ASCII
|
|
343
222
|
s = s.replace(HOMOGLYPH_SLASHES, '/').replace(HOMOGLYPH_DASHES, '-');
|
|
344
|
-
// 5. backslash → forward slash
|
|
345
223
|
s = s.replace(/\\/g, '/');
|
|
346
|
-
|
|
347
|
-
// BOTH '/' and '.' to '-', so a leading/embedded dash run reconstructs the
|
|
348
|
-
// original separators. Converting every '-' run to '/' is over-broad but
|
|
349
|
-
// safe for matching: CP1 anchors on `/Users/bernhardg` which only appears
|
|
350
|
-
// when the real path is present. Collapse runs of '-'.
|
|
351
|
-
s = s.replace(/-+/g, '/');
|
|
352
|
-
// collapse repeated slashes produced by the decodings
|
|
224
|
+
if (!preserveDashes) s = s.replace(/-+/g, '/');
|
|
353
225
|
s = s.replace(/\/{2,}/g, '/');
|
|
354
226
|
return s;
|
|
355
227
|
}
|
|
356
228
|
|
|
357
|
-
// ---------------------------------------------------------------------------
|
|
358
|
-
// Forbidden patterns (matched against canonical and/or raw form)
|
|
359
|
-
// ---------------------------------------------------------------------------
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* CP1: personal home path — matched on the CANONICAL (lowercased, slash-form)
|
|
363
|
-
* line. Because canonicalization collapses the slash-form (#631), the
|
|
364
|
-
* dash-encoded projects-dir form (#634), percent/unicode/backslash/homoglyph
|
|
365
|
-
* spellings into one string, a SINGLE rule replaces the old P1+P9 pair and
|
|
366
|
-
* catches future encodings structurally.
|
|
367
|
-
*
|
|
368
|
-
* Mirrors the ORIGINAL P1 regex EXACTLY, applied to the canonical (separator-
|
|
369
|
-
* normalized, case-PRESERVED) form: `/Users/bernhardg` + `[a-z.]*` (lowercase-
|
|
370
|
-
* letter / dot continuation — catches the full-username form
|
|
371
|
-
* `/Users/bernhardgoetzendorfer/`) + a trailing slash OR a word boundary.
|
|
372
|
-
*
|
|
373
|
-
* Because canonicalization is case-preserving, this single rule simultaneously:
|
|
374
|
-
* - is case-SENSITIVE on the host segment (`/Users`, not `/users`) — a
|
|
375
|
-
* deliberately-lowercased `/users/bernhardg.` literal does NOT match;
|
|
376
|
-
* - keeps the #631 fix (bare `/Users/bernhardg.` at EOL matches via `\b`);
|
|
377
|
-
* - keeps the old false-positive profile (the `[a-z.]*`-then-boundary stops
|
|
378
|
-
* at an uppercase / digit / underscore continuation):
|
|
379
|
-
* `/Users/bernhardgXfoo` → `X` (uppercase) stops `[a-z.]*`, `g`→`X` no `\b` → reject
|
|
380
|
-
* `/Users/bernhardg9` → `9` word-char, no `\b` after `g` → reject
|
|
381
|
-
* `/Users/bernhardg_home` → `_` word-char, no `\b` after `g` → reject
|
|
382
|
-
* `/Users/bernhardo` → diverges before `g` → reject
|
|
383
|
-
* `/Users/bernhardgoetzendorfer/` → `[a-z.]*` eats `oetzendorfer`, `/` → MATCH
|
|
384
|
-
*
|
|
385
|
-
* The win over the old P1+P9 pair: every ENCODED separator form (dash #634,
|
|
386
|
-
* percent, backslash, homoglyph, html-entity, unicode-escape, and any future
|
|
387
|
-
* spelling) collapses to `/Users/bernhardg…` in the canonical string, so ONE
|
|
388
|
-
* rule catches them all instead of a fresh regex per encoding.
|
|
389
|
-
*
|
|
390
|
-
* Finding 1 (HIGH): the USERNAME segment is matched CASE-INSENSITIVELY via an
|
|
391
|
-
* explicit per-letter token (`[bB][eE]…[gG]`) — not the `/i` flag, which would
|
|
392
|
-
* also loosen the host anchor, and not an inline `(?i:…)` group, which Node 20
|
|
393
|
-
* does not reliably support. On case-insensitive APFS, `/Users/Bernhardg.` is a
|
|
394
|
-
* real path identifying the operator, so a capitalized username must still be
|
|
395
|
-
* caught. Two case contracts are DELIBERATELY kept narrow:
|
|
396
|
-
* - the HOST segment stays case-SENSITIVE (`\/Users\/`, capital-U literal) so a
|
|
397
|
-
* lowercased `/users/bernhardg/x` source literal does NOT match;
|
|
398
|
-
* - the CONTINUATION class stays lowercase-only (`[a-z.]*`) so an UPPERCASE
|
|
399
|
-
* letter continuing the segment marks a DIFFERENT user and stops the match:
|
|
400
|
-
* `/Users/bernhardgXfoo` → `[a-z.]*` matches 0 chars, `g`→`X` (both word
|
|
401
|
-
* chars) gives no `\b` → reject (continuation-boundary survives).
|
|
402
|
-
*/
|
|
403
|
-
const CP1_CANON = /\/Users\/[bB][eE][rR][nN][hH][aA][rR][dD][gG][a-z.]*(\/|\b)/;
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Self-documentation guard: a line that QUOTES the scanner's own pattern regex
|
|
407
|
-
* (e.g. CHANGELOG / migration-doc prose `…added P9 /-Users-bernhardg[a-z.]*-/…`)
|
|
408
|
-
* contains the username token but is NOT a path — a real macOS home path never
|
|
409
|
-
* embeds a regex character-class `[…]` immediately after the username.
|
|
410
|
-
* This is the same self-reference class that SELF_EXCLUSIONS handles for the
|
|
411
|
-
* scanner's source + test files; here it is line-scoped so a REAL leak elsewhere
|
|
412
|
-
* in the same doc still fails. Without it, the #661 canonical rule (which is now
|
|
413
|
-
* as strict on the dash-form as P1 always was on the slash-form) would newly
|
|
414
|
-
* flag the pre-existing CHANGELOG entries that document the legacy P1/P9 regexes.
|
|
415
|
-
*
|
|
416
|
-
* NOTE: tested against the CANONICAL form, where the dash-run normalization has
|
|
417
|
-
* already turned the regex range `[a-z` into `[a/z`. We therefore anchor on the
|
|
418
|
-
* literal `[` right after `bernhardg` (`bernhardg\[`), which uniquely marks a
|
|
419
|
-
* quoted character-class and never appears in a real path segment. Username is
|
|
420
|
-
* matched case-insensitively to mirror the case-insensitive CP1 username token.
|
|
421
|
-
*
|
|
422
|
-
* Finding 2 (MED): the guard now BLANKS only the matched regex-quote TOKEN
|
|
423
|
-
* (replacing the `bernhardg` username with a sentinel) rather than suppressing
|
|
424
|
-
* the WHOLE line. A real leak that merely SHARES a line with a quoted regex
|
|
425
|
-
* (`Real: /Users/bernhardg/Projects/secret (see regex /Users/bernhardg[a-z.]*)`)
|
|
426
|
-
* is still caught: only the `…bernhardg[` token is neutralized, the real
|
|
427
|
-
* `/Users/bernhardg/…` path on the same line survives the residue re-scan.
|
|
428
|
-
* The `Users.bernhardg\[` anchor (with `.` matching the canonicalized slash)
|
|
429
|
-
* leaves the real-path form `Users/bernhardg/` — username followed by a SLASH,
|
|
430
|
-
* not a `[` — untouched.
|
|
431
|
-
*/
|
|
432
|
-
const SCANNER_REGEX_QUOTE_BLANK_G = /(Users.)[bB][eE][rR][nN][hH][aA][rR][dD][gG](\[)/g;
|
|
433
|
-
|
|
434
|
-
/** CP2: private GitLab host */
|
|
435
|
-
const CP2 = /\bgitlab\.gotzendorfer\.at\b/;
|
|
436
|
-
|
|
437
|
-
/** CP3: private events domain */
|
|
438
|
-
const CP3 = /\bevents\.gotzendorfer\.at\b/;
|
|
439
|
-
|
|
440
|
-
/** CP4: private package scope */
|
|
441
|
-
const CP4 = /@goetzendorfer\/[A-Za-z0-9*_-]+/;
|
|
442
|
-
|
|
443
|
-
/** CP5: DEFAULT_GITLAB_HOST on a line with 'gotzendorfer' OR as an exported const */
|
|
444
|
-
const CP5_WITH_GOTZ = /DEFAULT_GITLAB_HOST/;
|
|
445
|
-
const CP5_EXPORT = /\bexport\b.*\bconst\b.*\bDEFAULT_GITLAB_HOST\b/;
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* CP6 (tracked-file scan): private project slugs (word-boundary anchored,
|
|
449
|
-
* case-insensitive — #483 W4-Q6 caught "Buchhaltgenie" capitalized).
|
|
450
|
-
* The FULL 7-slug list — runScan uses this so the public-mirror guard is UNCHANGED.
|
|
451
|
-
*/
|
|
452
|
-
const CP6_PATTERNS = PRIVATE_SLUGS.map((slug) => new RegExp(`\\b${escapeRegex(slug)}\\b`, 'i'));
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* CP6 (in-process vault-namespace guard): PRIVATE_SLUGS minus VAULT_CLEAR_SLUGS
|
|
456
|
-
* (issue #59 owner decision 2026-07-18). isOwnerLeakySegment iterates THIS list,
|
|
457
|
-
* so carved-out slugs resolve to their own vault namespace instead of collapsing
|
|
458
|
-
* to 'redacted-repo', while the tracked-file scanner (CP6_PATTERNS) still blocks
|
|
459
|
-
* all 7 from leaking into the public mirror. Effectively only the 2 retained
|
|
460
|
-
* slugs (Codex-Hackathon, aiat-pmo-module). Case-insensitive: filter lowercases
|
|
461
|
-
* before the VAULT_CLEAR_SLUGS membership test, matching the 'i'-flag regex.
|
|
462
|
-
*/
|
|
463
|
-
const CP6_INPROCESS_PATTERNS = PRIVATE_SLUGS
|
|
464
|
-
.filter((slug) => !VAULT_CLEAR_SLUGS.has(slug.toLowerCase()))
|
|
465
|
-
.map((slug) => new RegExp(`\\b${escapeRegex(slug)}\\b`, 'i'));
|
|
466
|
-
|
|
467
|
-
/** CP7: catch-all gotzendorfer.at (must not match allowlist) */
|
|
468
|
-
const CP7 = /gotzendorfer\.at/;
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* GLOBAL twins of the two allowlist-consulting domain rules, used to count
|
|
472
|
-
* OCCURRENCES in the raw vs the canonical form of a line (#1080 Finding A).
|
|
473
|
-
*
|
|
474
|
-
* Why an occurrence COUNT and not simply a second `.test()` on the canonical
|
|
475
|
-
* form: CP3 and CP7 are the only rules whose verdict is filtered by
|
|
476
|
-
* isAllowlisted(), and that allowlist matches a LINE FORM — the sanctioned
|
|
477
|
-
* Impressum/author URL, and the exact events doc-comment contract line.
|
|
478
|
-
* Canonicalization folds every `-` run to `/`, which mangles precisely that
|
|
479
|
-
* prose: ` * - No literal ...` canonicalizes to ` * / No literal ...`. So
|
|
480
|
-
* re-running isAllowlisted() against the canonical form would fail to recognise
|
|
481
|
-
* its own sanctioned line and turn this gate — and with it .husky/pre-commit —
|
|
482
|
-
* permanently red on a clean tree. Measured, not reasoned: that exact exclusion
|
|
483
|
-
* regex returns true on the raw line and false on its canonical form.
|
|
484
|
-
*
|
|
485
|
-
* The occurrence count separates the two cases without touching the allowlist:
|
|
486
|
-
* when the canonical form carries MORE domain tokens than the raw line, the
|
|
487
|
-
* surplus was produced by DECODING an escape, and a decoded token can never be
|
|
488
|
-
* the sanctioned publication the allowlist covers — so it bypasses the allowlist.
|
|
489
|
-
* When the counts are equal, the rule behaves exactly as it always did and
|
|
490
|
-
* consults the allowlist on the raw line.
|
|
491
|
-
*/
|
|
492
|
-
const CP3_G = /\bevents\.gotzendorfer\.at\b/g;
|
|
493
|
-
const CP7_G = /gotzendorfer\.at/g;
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Count matches of a GLOBAL regex in `s` (0 when none).
|
|
497
|
-
* @param {RegExp} re a regex carrying the `g` flag
|
|
498
|
-
* @param {string} s
|
|
499
|
-
* @returns {number}
|
|
500
|
-
*/
|
|
501
|
-
function countMatches(re, s) {
|
|
502
|
-
return (s.match(re) || []).length;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/**
|
|
506
|
-
* CP8: full RFC1918 private dotted-quad — internal-IP leak.
|
|
507
|
-
* Matches only literal 4-octet private IPs (10.x.x.x, 192.168.x.x, 172.16-31.x.x).
|
|
508
|
-
* Deliberately does NOT match placeholder `.x` forms (10.x, 192.168.x) or CIDR/range
|
|
509
|
-
* notation used in SSRF-range documentation, nor TEST-NET (192.0.2.x, RFC 5737).
|
|
510
|
-
*/
|
|
511
229
|
const CP8 = /(?:\b10(?:\.\d{1,3}){3}|\b192\.168(?:\.\d{1,3}){2}|\b172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2})/;
|
|
512
230
|
|
|
513
|
-
/** CP8 allowlist: files where RFC1918 IPs are a legitimate test subject (IP-redaction fixtures). */
|
|
514
231
|
const CP8_ALLOWLIST = new Set(['tests/scripts/export-hw-learnings.test.mjs']);
|
|
515
232
|
|
|
516
|
-
/**
|
|
517
|
-
* Match an owner personal-home-path leak in a line, using canonicalization.
|
|
518
|
-
* Returns the offending class label, or null when clean.
|
|
519
|
-
*
|
|
520
|
-
* Strategy: canonicalize separators/escapes (case preserved), then run the
|
|
521
|
-
* single CP1 rule once. Because canonicalization collapses every known
|
|
522
|
-
* encoding of `/Users/bernhardg…` to one string while preserving letter case,
|
|
523
|
-
* this ONE structural rule replaces the P1-bare-slash + P9-dash regex
|
|
524
|
-
* treadmill AND catches future encodings (percent / backslash / homoglyph /
|
|
525
|
-
* html-entity / unicode-escape / dash) without a new regex per form.
|
|
526
|
-
* @param {string} line
|
|
527
|
-
* @returns {string|null}
|
|
528
|
-
*/
|
|
529
233
|
export function matchOwnerPath(line) {
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
// re-scan the RESIDUE. This neutralizes prose that quotes the scanner's own
|
|
534
|
-
// pattern (`…bernhardg[a-z.]*…` — not a path) WITHOUT suppressing a real leak
|
|
535
|
-
// that happens to share the line. Tested against the CANONICAL form because
|
|
536
|
-
// the documented regex source spells separators with `\/` (escaped slash)
|
|
537
|
-
// which only normalizes to `/` after canonicalization. See
|
|
538
|
-
// SCANNER_REGEX_QUOTE_BLANK_G.
|
|
539
|
-
const residue = canon.replace(SCANNER_REGEX_QUOTE_BLANK_G, '$1__REGEXQUOTE__$2');
|
|
540
|
-
if (CP1_CANON.test(residue)) return 'CP1 (personal home path — canonicalized)';
|
|
541
|
-
return null;
|
|
234
|
+
const { CP1_CANON, SCANNER_REGEX_QUOTE_BLANK_G } = currentOwnerRules();
|
|
235
|
+
const residue = canonicalizeLine(line).replace(SCANNER_REGEX_QUOTE_BLANK_G, '$1__REGEXQUOTE__$2');
|
|
236
|
+
return CP1_CANON.test(residue) ? 'CP1 (personal home path — canonicalized)' : null;
|
|
542
237
|
}
|
|
543
238
|
|
|
544
|
-
/** CP10: personal-name owner segment in a ~/Projects/<Name>/ path (#653).
|
|
545
|
-
* Name-denylist driven (CLOSED list, audit-reviewed) — mirrors PRIVATE_SLUGS (CP6).
|
|
546
|
-
* A generic ~/Projects/[A-Z][a-z]+/ would false-positive on legit capitalized
|
|
547
|
-
* project dirs (~/Projects/MyApp/), so we match only known personal names.
|
|
548
|
-
*
|
|
549
|
-
* Matches the tilde form (`~/Projects/Bernhard`) OR an absolute home form
|
|
550
|
-
* (`/Users/<user>/Projects/Bernhard`, `/home/<user>/Projects/Bernhard`), then a
|
|
551
|
-
* trailing-slash OR word boundary. The slash-OR-word-boundary alternation is the
|
|
552
|
-
* Finding-1 fix for the original `…/<Name>/` form, which REQUIRED a slash after the
|
|
553
|
-
* name and so let a bare `~/Projects/Bernhard` (end-of-line, before `&&`) pass
|
|
554
|
-
* undetected — the same blindspot CP1 was patched for (see CP1 doc above). The
|
|
555
|
-
* absolute-home alternation is the Finding-3 defense-in-depth: a leak in a
|
|
556
|
-
* non-owner home (`/Users/alice/Projects/Bernhard/vault`) slipped both CP1 and the
|
|
557
|
-
* old tilde-only CP10. The trailing word boundary keeps the false-positive profile
|
|
558
|
-
* tight: `~/Projects/Bernhardt/` (denylisted name + a continuation letter) does
|
|
559
|
-
* NOT match, so names that merely START with a denylisted name are not flagged. */
|
|
560
|
-
const PERSONAL_NAMES = ['Bernhard'];
|
|
561
|
-
const CP10_PATTERNS = PERSONAL_NAMES.map(
|
|
562
|
-
(name) => new RegExp(`(?:~|/Users/[^/]+|/home/[^/]+)/Projects/${escapeRegex(name)}(\\/|\\b)`),
|
|
563
|
-
);
|
|
564
|
-
|
|
565
|
-
/** CP10 allowlist: files where ~/Projects/Bernhard is a deliberate test subject
|
|
566
|
-
* (migration/drift fixtures) or a legitimate one-shot-migration source/target. */
|
|
567
239
|
const CP10_ALLOWLIST = new Set([
|
|
568
240
|
'tests/scripts/vault-consolidate.test.mjs',
|
|
569
241
|
'tests/scripts/migrate-vault-paths.test.mjs',
|
|
@@ -579,149 +251,16 @@ function escapeRegex(s) {
|
|
|
579
251
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
580
252
|
}
|
|
581
253
|
|
|
582
|
-
// DELIBERATE DUPLICATE — do not replace the function below with an import from
|
|
583
|
-
// `scripts/lib/redact-spans.mjs`. This scanner is a documented STANDALONE
|
|
584
|
-
// SINGLE-FILE vendoring target (`.claude/rules/security.md` § "Owner-Privacy
|
|
585
|
-
// Pre-Commit Hook": consumer repos copy exactly this ONE file into their tree as
|
|
586
|
-
// a pre-commit stage). A static import resolves in-tree but throws
|
|
587
|
-
// ERR_MODULE_NOT_FOUND in every vendored copy — under the husky stage that means
|
|
588
|
-
// empty stdout + exit 1 on a CLEAN tree, i.e. every commit blocked. Not
|
|
589
|
-
// hypothetical: `tests/husky/pre-commit-owner-leakage.test.mjs` cpSync()s this
|
|
590
|
-
// single file into a tmp repo, and the static-import variant turned all three of
|
|
591
|
-
// its cases red (#974).
|
|
592
|
-
//
|
|
593
|
-
// The dynamic-import degrade used by getConfidentialNamePatterns() is NOT
|
|
594
|
-
// available here. That helper degrades to `[]` for the standalone-copy case alone
|
|
595
|
-
// — CP11 goes inert, CP1–CP10 keep running, nothing leaks (every OTHER load
|
|
596
|
-
// failure there is now a counted FAIL, #1244). A failed REDACTION has the opposite
|
|
597
|
-
// failure direction:
|
|
598
|
-
// it prints confidential names verbatim into a PUBLIC GitHub-Actions log, which
|
|
599
|
-
// is precisely the exposure this function exists to prevent (Fix 1 below). The
|
|
600
|
-
// redaction sink must be unconditionally present, so it lives inline.
|
|
601
|
-
//
|
|
602
|
-
// `scripts/lib/redact-spans.mjs` is the shared primitive for IN-TREE consumers
|
|
603
|
-
// (e.g. `scripts/lib/secret-masker.mjs`); this copy serves the vendored path.
|
|
604
|
-
// The two are pinned byte-for-byte against each other by the drift guard in
|
|
605
|
-
// `tests/lib/redact-spans.test.mjs` — change one and that suite goes red until
|
|
606
|
-
// both agree again.
|
|
607
|
-
/**
|
|
608
|
-
* Redact every confidential-name span from `line`, ORDER-INDEPENDENTLY (Fix 1 + Fix 2).
|
|
609
|
-
*
|
|
610
|
-
* This is the single redaction sink for the confidential-names privacy invariant.
|
|
611
|
-
* It is applied at the print choke-point over EVERY violation's lineContent — not
|
|
612
|
-
* only CP11 hits — because this scanner runs in a PUBLIC GitHub-Actions mirror: a
|
|
613
|
-
* confidential customer/repo name that co-occurs with a CP1–CP10 hit on the same
|
|
614
|
-
* line (e.g. a name beside an RFC1918 IP that fails CP8) would otherwise be echoed
|
|
615
|
-
* verbatim to the public CI log (Fix 1).
|
|
616
|
-
*
|
|
617
|
-
* ORDER-INDEPENDENCE (Fix 2): a naïve chain of `.replace()` calls is order-dependent
|
|
618
|
-
* — when one configured name is a PREFIX of another (`['acme','acme-corp-secret']`),
|
|
619
|
-
* redacting the shorter first destroys the longer's match and leaks a suffix residue
|
|
620
|
-
* (`[REDACTED]-corp-secret`). Instead we compute ALL match spans against the ORIGINAL
|
|
621
|
-
* (unmutated) string across every pattern, merge overlapping/adjacent intervals, and
|
|
622
|
-
* splice `[REDACTED]` per merged interval. No pattern ever sees a string another
|
|
623
|
-
* pattern already rewrote, so prefix/suffix overlap cannot leak regardless of list
|
|
624
|
-
* order.
|
|
625
|
-
*
|
|
626
|
-
* @param {string} line — the raw (already-trimmed) violation lineContent.
|
|
627
|
-
* @param {RegExp[]} patterns — confidential-name regexes (word-boundary, case-insensitive).
|
|
628
|
-
* @returns {string} the line with every configured name span replaced by [REDACTED].
|
|
629
|
-
*/
|
|
630
|
-
function redactSpans(line, patterns) {
|
|
631
|
-
if (!Array.isArray(patterns) || patterns.length === 0) return line;
|
|
632
|
-
|
|
633
|
-
// 1. Collect [start, end) spans of every match of every pattern against the
|
|
634
|
-
// ORIGINAL line (never a partially-mutated one). Global clone so exec() walks
|
|
635
|
-
// all matches; zero-width guard prevents an infinite loop on a degenerate regex.
|
|
636
|
-
const spans = [];
|
|
637
|
-
for (const re of patterns) {
|
|
638
|
-
const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
|
|
639
|
-
let m;
|
|
640
|
-
while ((m = g.exec(line)) !== null) {
|
|
641
|
-
if (m[0].length === 0) {
|
|
642
|
-
g.lastIndex += 1;
|
|
643
|
-
continue;
|
|
644
|
-
}
|
|
645
|
-
spans.push([m.index, m.index + m[0].length]);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
if (spans.length === 0) return line;
|
|
649
|
-
|
|
650
|
-
// 2. Merge overlapping / adjacent intervals (sorted by start).
|
|
651
|
-
spans.sort((a, b) => a[0] - b[0]);
|
|
652
|
-
const merged = [];
|
|
653
|
-
for (const [s, e] of spans) {
|
|
654
|
-
const last = merged[merged.length - 1];
|
|
655
|
-
if (last && s <= last[1]) {
|
|
656
|
-
last[1] = Math.max(last[1], e);
|
|
657
|
-
} else {
|
|
658
|
-
merged.push([s, e]);
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// 3. Splice [REDACTED] per merged interval, left-to-right over the ORIGINAL line.
|
|
663
|
-
let out = '';
|
|
664
|
-
let cursor = 0;
|
|
665
|
-
for (const [s, e] of merged) {
|
|
666
|
-
out += line.slice(cursor, s) + '[REDACTED]';
|
|
667
|
-
cursor = e;
|
|
668
|
-
}
|
|
669
|
-
out += line.slice(cursor);
|
|
670
|
-
return out;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
/**
|
|
674
|
-
* The THREE modules getConfidentialNamePatterns() imports directly, as absolute
|
|
675
|
-
* URLs resolved against THIS file. Used only to classify an ERR_MODULE_NOT_FOUND:
|
|
676
|
-
* `err.url` carries the URL of the module that could not be found (measured on
|
|
677
|
-
* Node 24 — a relative specifier yields `url`, a bare package specifier yields
|
|
678
|
-
* none), so a miss on one of these three is the standalone single-file copy,
|
|
679
|
-
* while a miss anywhere DEEPER (a transitive of an in-tree helper, or a bare
|
|
680
|
-
* package) is a broken install that must fail CLOSED rather than go inert.
|
|
681
|
-
*/
|
|
682
254
|
const CP11_DIRECT_SIBLING_URLS = new Set(
|
|
683
255
|
['../config/host-paths.mjs', './confidential-names.mjs', '../owner-yaml.mjs'].map(
|
|
684
256
|
(spec) => new URL(spec, import.meta.url).href,
|
|
685
257
|
),
|
|
686
258
|
);
|
|
687
259
|
|
|
688
|
-
/**
|
|
689
|
-
* True when an ERR_MODULE_NOT_FOUND names one of this scanner's own three DIRECT
|
|
690
|
-
* helper imports — i.e. the documented standalone-vendoring shape. False for a
|
|
691
|
-
* transitive relative module or a bare package (no `err.url` at all), which is a
|
|
692
|
-
* broken in-tree install: CP11 then fails closed instead of silently returning
|
|
693
|
-
* zero patterns while names ARE configured.
|
|
694
|
-
*
|
|
695
|
-
* @param {{ url?: string }} err
|
|
696
|
-
* @returns {boolean}
|
|
697
|
-
*/
|
|
698
260
|
function isMissingDirectSibling(err) {
|
|
699
261
|
return typeof err?.url === 'string' && CP11_DIRECT_SIBLING_URLS.has(err.url);
|
|
700
262
|
}
|
|
701
263
|
|
|
702
|
-
/**
|
|
703
|
-
* Was `paths.confidential-names-file` actually written into owner.yaml?
|
|
704
|
-
*
|
|
705
|
-
* WHY THIS RE-READS THE FILE. `loadOwnerConfig()` reports an invalid OPTIONAL
|
|
706
|
-
* section only as `droppedSections: [{ section: 'paths', errors }]` and replaces
|
|
707
|
-
* `config.paths` with the DEFAULTS — the raw keys of the dropped section are not
|
|
708
|
-
* recoverable from its return value, and its `errors[]` name a key only for the
|
|
709
|
-
* per-key `paths.<key> must be a string` case (a `paths: 42` shape names none).
|
|
710
|
-
* So the one question CP11's verdict turns on — did the operator configure a
|
|
711
|
-
* names file AT ALL — has no answer in the loader's contract today. Re-parsing
|
|
712
|
-
* the file for that single key is the minimal derivation; widening the loader's
|
|
713
|
-
* return shape would touch every one of its callers.
|
|
714
|
-
*
|
|
715
|
-
* Only reached when the YAML already parsed once inside loadOwnerConfig (the
|
|
716
|
-
* dropped-section branch implies that), so `js-yaml` is resolvable here; 'unknown'
|
|
717
|
-
* is the defensive residue and makes the caller fail closed.
|
|
718
|
-
*
|
|
719
|
-
* Returns a CLASS, never the value: the configured path is host-local and this
|
|
720
|
-
* scanner's output is captured by a PUBLIC CI mirror (see the no-path rule above).
|
|
721
|
-
*
|
|
722
|
-
* @param {string} ownerYamlPath
|
|
723
|
-
* @returns {'configured'|'absent'|'unknown'}
|
|
724
|
-
*/
|
|
725
264
|
function rawConfidentialNamesKeyState(ownerYamlPath) {
|
|
726
265
|
let parsed;
|
|
727
266
|
try {
|
|
@@ -732,92 +271,23 @@ function rawConfidentialNamesKeyState(ownerYamlPath) {
|
|
|
732
271
|
}
|
|
733
272
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return 'unknown';
|
|
734
273
|
const paths = parsed.paths;
|
|
735
|
-
// `paths:` absent, null, or not a mapping at all → the key cannot be in there.
|
|
736
274
|
if (paths === null || typeof paths !== 'object' || Array.isArray(paths)) return 'absent';
|
|
737
275
|
const value = paths['confidential-names-file'];
|
|
738
276
|
if (value === undefined || value === null) return 'absent';
|
|
739
|
-
// A non-string (or empty-string) value is still an ATTEMPT to configure CP11 —
|
|
740
|
-
// except '' , which is the schema's documented "no override" spelling.
|
|
741
277
|
if (typeof value === 'string' && value.trim() === '') return 'absent';
|
|
742
278
|
return 'configured';
|
|
743
279
|
}
|
|
744
280
|
|
|
745
|
-
/**
|
|
746
|
-
* CP11: build word-boundary, case-insensitive regexes from the host-local
|
|
747
|
-
* confidential-names list (#728a). Mirrors CP6_PATTERNS (private slugs), but the
|
|
748
|
-
* name source is LOADED from a never-committed host-local file instead of a
|
|
749
|
-
* closed in-source array — the confidential customer/repo names must never live
|
|
750
|
-
* in a committed file.
|
|
751
|
-
*
|
|
752
|
-
* LAZY BY CONTRACT: this reads the host-local file (owner.yaml + env), so it MUST
|
|
753
|
-
* be called only from runScan() — NOT at module top-level. This module is
|
|
754
|
-
* dual-mode (CLI entry point AND importable library, `isMain` guard); a top-level
|
|
755
|
-
* fs read would fire on every library import (e.g. pseudonym-map.mjs importing
|
|
756
|
-
* isOwnerLeakySegment). inspectConfidentialNames caches per path, so calling this
|
|
757
|
-
* once per scan reads the file at most once.
|
|
758
|
-
*
|
|
759
|
-
* STANDALONE-SAFE: the two host-local helper modules are DYNAMICALLY imported
|
|
760
|
-
* here (not statically at the top of the file) so a single-file copy of this
|
|
761
|
-
* scanner — the documented vendoring pattern (security.md § Owner-Privacy: "Reuse
|
|
762
|
-
* the same scanner"; exercised by tests/husky/pre-commit-owner-leakage.test.mjs,
|
|
763
|
-
* which copies ONLY this file into a tmp repo) — does not crash at module load
|
|
764
|
-
* with ERR_MODULE_NOT_FOUND. When the helpers are unresolvable (or throw), CP11
|
|
765
|
-
* degrades to inert ([] patterns) and CP1–CP10 run unchanged.
|
|
766
|
-
*
|
|
767
|
-
* FAIL CLOSED WHEN CP11 WAS EXPECTED (GitLab #1244). The single bare
|
|
768
|
-
* `try { … } catch { return [] }` this function used to be conflated three
|
|
769
|
-
* outcomes that must not share a verdict, and printed `PASS` for all three:
|
|
770
|
-
* (a) the helpers are unresolvable — the STANDALONE single-file copy. The
|
|
771
|
-
* intended degrade, and the ONLY one: inert + one WARN line, exit unchanged.
|
|
772
|
-
* (b) CP11 is not configured at all (no names file, no env) — the ~99% default:
|
|
773
|
-
* inactive, silent, PASS. Unchanged. This INCLUDES an owner.yaml whose
|
|
774
|
-
* `paths:` section was dropped as invalid for some OTHER key (the #1244
|
|
775
|
-
* fix over-reached here and failed every commit on such a host): the raw
|
|
776
|
-
* key is re-read (rawConfidentialNamesKeyState) and, when absent, CP11 is
|
|
777
|
-
* INACTIVE — one WARN naming the dropped section, no FAIL, exit unchanged.
|
|
778
|
-
* (c) CP11 IS configured — env set, a names file resolved, or the raw
|
|
779
|
-
* `paths.confidential-names-file` key present in a `paths:` section that
|
|
780
|
-
* was dropped as invalid — or its configuration is unknowable because
|
|
781
|
-
* owner.yaml exists but cannot be parsed (e.g. `js-yaml` missing) — and
|
|
782
|
-
* could not be loaded. Previously indistinguishable from (b): the scanner matched
|
|
783
|
-
* nothing and printed `PASS: no owner-privacy leakage found`. A guard that
|
|
784
|
-
* cannot run must say so and FAIL, never report the clean verdict it did
|
|
785
|
-
* not earn — so this returns a `disabledReason` that runScan turns into a
|
|
786
|
-
* `CP11 DISABLED` stderr line plus a counted FAIL (exit 1).
|
|
787
|
-
*
|
|
788
|
-
* The reason strings deliberately carry NO PATH. This scanner's stdout+stderr are
|
|
789
|
-
* captured by a PUBLIC GitHub-Actions mirror, and the confidential-names path is
|
|
790
|
-
* host-local — echoing it there would leak the very `/Users/<name>/…` shape CP1
|
|
791
|
-
* exists to block. The operator knows their own path; the CLASS of failure is what
|
|
792
|
-
* this line has to convey.
|
|
793
|
-
*
|
|
794
|
-
* @param {{ loadHostPaths?: typeof import('../config/host-paths.mjs').loadHostPaths }} [opts]
|
|
795
|
-
* @returns {Promise<{ patterns: RegExp[], disabledReason?: string, inertWarn?: string }>}
|
|
796
|
-
*/
|
|
797
281
|
export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
798
282
|
let helpers;
|
|
799
283
|
try {
|
|
800
284
|
helpers = {
|
|
801
285
|
hostPaths: await import('../config/host-paths.mjs'),
|
|
802
286
|
confidentialNames: await import('./confidential-names.mjs'),
|
|
803
|
-
// Already a transitive dependency (host-paths.mjs imports it statically), so
|
|
804
|
-
// this adds no file to the standalone-copy chain the husky E2E mirrors.
|
|
805
287
|
ownerYaml: await import('../owner-yaml.mjs'),
|
|
806
288
|
};
|
|
807
289
|
} catch (err) {
|
|
808
290
|
if (err?.code === 'ERR_MODULE_NOT_FOUND' && isMissingDirectSibling(err)) {
|
|
809
|
-
// (a) standalone single-file vendoring. The degrade is documented — but it
|
|
810
|
-
// must not swallow a CP11 that the operator DID configure (GitLab #1262).
|
|
811
|
-
// No helper resolved here, so the only configuration signal still readable
|
|
812
|
-
// is the raw env var: a literal mirror of ENV_KEYS['confidential-names-file']
|
|
813
|
-
// in scripts/lib/config/host-paths.mjs, which cannot be imported on this
|
|
814
|
-
// branch by construction.
|
|
815
|
-
//
|
|
816
|
-
// LIMIT (named, not hidden): an owner.yaml-configured names file is
|
|
817
|
-
// STRUCTURALLY undetectable in a standalone copy — resolveOwnerYamlPath()
|
|
818
|
-
// lives in the very module chain that failed to resolve, so there is no way
|
|
819
|
-
// to learn that owner.yaml even exists, let alone what it configures. Only
|
|
820
|
-
// the env route can fail closed here; the owner.yaml route degrades inert.
|
|
821
291
|
const envNamesPath = process.env.SO_CONFIDENTIAL_NAMES_FILE;
|
|
822
292
|
if (typeof envNamesPath === 'string' && envNamesPath.trim() !== '') {
|
|
823
293
|
return {
|
|
@@ -831,7 +301,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
831
301
|
inertWarn: 'CP11 inert — confidential-names helpers not resolvable (standalone copy)',
|
|
832
302
|
};
|
|
833
303
|
}
|
|
834
|
-
// Any OTHER import failure is a broken in-tree install, not the vendoring case.
|
|
835
304
|
return { patterns: [], disabledReason: `confidential-names helpers failed to load (${err?.name ?? 'Error'})` };
|
|
836
305
|
}
|
|
837
306
|
|
|
@@ -840,23 +309,8 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
840
309
|
const { inspectConfidentialNames } = helpers.confidentialNames;
|
|
841
310
|
const { resolveOwnerYamlPath } = helpers.ownerYaml;
|
|
842
311
|
|
|
843
|
-
// ONE owner.yaml load. Since #1251 loadHostPaths() passes the loader's own
|
|
844
|
-
// health (source / reason / droppedSections) straight through, so the
|
|
845
|
-
// env>owner.yaml>default precedence and the load's diagnosis come from the
|
|
846
|
-
// same read — no second loadOwnerConfig() call to recover what was discarded.
|
|
847
312
|
const hostCtx = (loadHostPaths ?? helpers.hostPaths.loadHostPaths)();
|
|
848
313
|
|
|
849
|
-
// FAIL-CLOSED on an unknowable load (LOW-2). `loadHostPaths` has its own
|
|
850
|
-
// defensive catch that swallows a THROWING owner loader and returns
|
|
851
|
-
// `{ ownerConfig: undefined, env, source: undefined, reason: undefined }`.
|
|
852
|
-
// Without this branch that shape resolves `namesPath` to '' , fires no
|
|
853
|
-
// reason branch, and returns `{ patterns: [] }` — a silent PASS.
|
|
854
|
-
// Only the throw produces all-undefined: `loadOwnerConfig` returns
|
|
855
|
-
// `{ config: getDefaults(), source: 'defaults', errors: [] }` for a
|
|
856
|
-
// genuinely ABSENT owner.yaml (owner-yaml.mjs:587-590) and `source:
|
|
857
|
-
// 'defaults'` with a `reason` for every read/parse failure — so a missing
|
|
858
|
-
// file keeps taking the normal path below. Latent today (the loader
|
|
859
|
-
// returns on every error path), load-bearing if that ever changes.
|
|
860
314
|
if (
|
|
861
315
|
hostCtx.ownerConfig === undefined &&
|
|
862
316
|
hostCtx.source === undefined &&
|
|
@@ -872,9 +326,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
872
326
|
const namesPath = resolveHostPath('confidential-names-file', '', hostCtx);
|
|
873
327
|
|
|
874
328
|
if (typeof namesPath !== 'string' || namesPath.trim() === '') {
|
|
875
|
-
// Nothing resolves a names file. That is either (b) — genuinely
|
|
876
|
-
// unconfigured — or a state in which the answer is UNKNOWABLE because the
|
|
877
|
-
// owner.yaml that would carry it could not be read. Unknowable is (c).
|
|
878
329
|
if (existsSync(resolveOwnerYamlPath())) {
|
|
879
330
|
if (hostCtx.reason === 'yaml-parser-missing') {
|
|
880
331
|
return {
|
|
@@ -891,10 +342,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
891
342
|
};
|
|
892
343
|
}
|
|
893
344
|
if (hostCtx.droppedSections?.some((d) => d.section === 'paths')) {
|
|
894
|
-
// The paths: section was replaced by its default because SOME key in it
|
|
895
|
-
// is invalid — which says nothing yet about whether CP11 was configured.
|
|
896
|
-
// Re-read the RAW file for that one key (the loader does not expose it on
|
|
897
|
-
// this branch; see rawConfidentialNamesKeyState) and only then decide.
|
|
898
345
|
const rawKey = rawConfidentialNamesKeyState(resolveOwnerYamlPath());
|
|
899
346
|
if (rawKey === 'configured') {
|
|
900
347
|
return {
|
|
@@ -910,8 +357,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
910
357
|
'owner.yaml has an invalid paths: section and could not be re-read, so a configured confidential-names-file cannot be ruled out',
|
|
911
358
|
};
|
|
912
359
|
}
|
|
913
|
-
// 'absent' — CP11 was never configured here. Inactive, not disabled:
|
|
914
|
-
// ONE WARN naming the dropped section, no FAIL, exit unchanged.
|
|
915
360
|
return {
|
|
916
361
|
patterns: [],
|
|
917
362
|
inertWarn:
|
|
@@ -922,15 +367,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
922
367
|
return { patterns: [] }; // (b) the ~99% default — inactive, silent.
|
|
923
368
|
}
|
|
924
369
|
|
|
925
|
-
// A names file IS configured. Since #1250 the loader REPORTS the class it
|
|
926
|
-
// used to collapse into `null`, so the verdict reads straight off `status` —
|
|
927
|
-
// no second read of the file to re-derive it. Only `[]` ('empty') is an
|
|
928
|
-
// operator choice (inactive, silent); 'missing'/'malformed'/'all-dropped'
|
|
929
|
-
// mean a configured guard cannot run, which is (c) and fails closed. The
|
|
930
|
-
// reasons carry no path (see above). `inspectConfidentialNames` is the
|
|
931
|
-
// discriminated entry point; `loadConfidentialNames` keeps its 4.0.0
|
|
932
|
-
// `string[] | null` shape for external deep-importers and cannot express
|
|
933
|
-
// this distinction.
|
|
934
370
|
const { status, names } = inspectConfidentialNames({ namesPath });
|
|
935
371
|
if (status === 'missing') {
|
|
936
372
|
return { patterns: [], disabledReason: 'a confidential-names-file is configured but does not exist' };
|
|
@@ -942,10 +378,6 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
942
378
|
};
|
|
943
379
|
}
|
|
944
380
|
if (status === 'all-dropped') {
|
|
945
|
-
// W4 finding F3 (fail-open): the file listed entries and validation rejected
|
|
946
|
-
// every one of them (non-strings, blanks, over-long). That is a CORRUPTED
|
|
947
|
-
// list, not the `[]` opt-out — CP11 would run with zero patterns and report
|
|
948
|
-
// the clean verdict it never earned. Same class as 'malformed'.
|
|
949
381
|
return {
|
|
950
382
|
patterns: [],
|
|
951
383
|
disabledReason:
|
|
@@ -955,39 +387,10 @@ export async function getConfidentialNamePatterns({ loadHostPaths } = {}) {
|
|
|
955
387
|
if (status !== 'ok') return { patterns: [] }; // 'empty' — the operator's `[]` opt-out.
|
|
956
388
|
return { patterns: names.map((name) => new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')) };
|
|
957
389
|
} catch (err) {
|
|
958
|
-
// The helpers resolved but something below threw. CP11 cannot run — fail closed.
|
|
959
390
|
return { patterns: [], disabledReason: `confidential-names resolution failed (${err?.name ?? 'Error'})` };
|
|
960
391
|
}
|
|
961
392
|
}
|
|
962
393
|
|
|
963
|
-
// ---------------------------------------------------------------------------
|
|
964
|
-
// Text-scan extension allowlist (spec A.2)
|
|
965
|
-
// ---------------------------------------------------------------------------
|
|
966
|
-
// '.html' (#1076): the site/ tree is PUBLICLY SHIPPED (vercel.json outputDirectory:
|
|
967
|
-
// "site"), so it is the highest-consequence class to scan, yet it was ungated here and
|
|
968
|
-
// therefore invisible to all eleven CP rules.
|
|
969
|
-
//
|
|
970
|
-
// CORRECTION (#1080 Finding A). An earlier revision of this comment justified '.html'
|
|
971
|
-
// partly by claiming its addition "revives canonicalizeLine()'s HTML-entity decoding,
|
|
972
|
-
// which was dead in practice". That was true of exactly ONE of the eleven rules.
|
|
973
|
-
// matchOwnerPath() (CP1) was the SOLE consumer of the canonical form — CP2-CP8, CP10
|
|
974
|
-
// and CP11 each tested the RAW line — so an entity-encoded private host inside an href
|
|
975
|
-
// (a link the BROWSER resolves and the scanner did not) still reported nothing. The
|
|
976
|
-
// per-line canonical re-test in the scan loop below is what actually closes that, and
|
|
977
|
-
// only for the four dot-anchored rules; see the `canon` comment there for why the rest
|
|
978
|
-
// stay raw.
|
|
979
|
-
//
|
|
980
|
-
// '.xml', '.svg', '.css' (#1080 Finding B): the PUBLICLY-SHIPPED sentence above is true
|
|
981
|
-
// VERBATIM of site/sitemap.xml and site/favicon.svg — same directory, same publication,
|
|
982
|
-
// same consequence — which the '.html' addition walked past. Measured with one identical
|
|
983
|
-
// planted defect per class before adding: the .html and .txt copies FAILed, the .xml and
|
|
984
|
-
// .svg copies reported nothing. Cost is zero: the five tracked files in these classes
|
|
985
|
-
// (assets/icon.svg, assets/og-card.svg, site/favicon.svg, site/sitemap.xml,
|
|
986
|
-
// templates/static-html/styles.css) carry no hits — 1542 -> 1547 scanned, 0 findings.
|
|
987
|
-
// `.jsonl` closes a gap this very wave opened: the harvested golden-record fixture at
|
|
988
|
-
// tests/lib/vault-mirror/fixtures/golden-sessions.jsonl is tracked production data that
|
|
989
|
-
// `.json` does not match, so the scanner would have skipped it for good. Measured before
|
|
990
|
-
// adding: all 7 tracked `.jsonl` files pass (1525 -> 1532 scanned, 0 findings).
|
|
991
394
|
const TEXT_EXTS = new Set([
|
|
992
395
|
'.md',
|
|
993
396
|
'.mdx',
|
|
@@ -1006,9 +409,6 @@ const TEXT_EXTS = new Set([
|
|
|
1006
409
|
'.css',
|
|
1007
410
|
]);
|
|
1008
411
|
|
|
1009
|
-
// Dotfiles to include (checked by basename, BEFORE the extension gate —
|
|
1010
|
-
// extname('.env.example') is '.example' (truthy), so an extension-first check
|
|
1011
|
-
// would make that allowlist entry unreachable; W3-P3 finding, 2026-06-10)
|
|
1012
412
|
const DOTFILE_ALLOWLIST = new Set(['.env.example', '.nvmrc', '.vault.yaml']);
|
|
1013
413
|
|
|
1014
414
|
function isTextFile(filePath) {
|
|
@@ -1018,16 +418,8 @@ function isTextFile(filePath) {
|
|
|
1018
418
|
return ext ? TEXT_EXTS.has(ext) : false;
|
|
1019
419
|
}
|
|
1020
420
|
|
|
1021
|
-
// ---------------------------------------------------------------------------
|
|
1022
|
-
// File enumeration: git ls-files primary, recursive fs walk fallback (spec A.2)
|
|
1023
|
-
// Exclusions: .git/, node_modules/, .orchestrator/audits/
|
|
1024
|
-
// ---------------------------------------------------------------------------
|
|
1025
|
-
|
|
1026
421
|
function getTrackedFiles() {
|
|
1027
422
|
try {
|
|
1028
|
-
// NUL delimiters preserve Unicode, spaces and newlines without Git's quoted
|
|
1029
|
-
// filename encoding. --exclude-standard applies only to untracked entries;
|
|
1030
|
-
// tracked files remain visible even if an ignore rule now matches them.
|
|
1031
423
|
const args = ['ls-files', '-z', '--cached'];
|
|
1032
424
|
if (includeUntracked) args.push('--others', '--exclude-standard');
|
|
1033
425
|
const output = execFileSync('git', args, { cwd: pluginRoot, encoding: 'utf8' });
|
|
@@ -1036,7 +428,6 @@ function getTrackedFiles() {
|
|
|
1036
428
|
.filter(Boolean)
|
|
1037
429
|
.map((f) => join(pluginRoot, f));
|
|
1038
430
|
} catch {
|
|
1039
|
-
// git unavailable or not a git repo — fall back to recursive fs walk
|
|
1040
431
|
return walkDir(pluginRoot);
|
|
1041
432
|
}
|
|
1042
433
|
}
|
|
@@ -1050,7 +441,6 @@ function walkDir(dir, acc = []) {
|
|
|
1050
441
|
}
|
|
1051
442
|
for (const entry of entries) {
|
|
1052
443
|
const full = join(dir, entry);
|
|
1053
|
-
// Exclusions: .git, node_modules, .orchestrator/audits
|
|
1054
444
|
if (entry === '.git' || entry === 'node_modules') continue;
|
|
1055
445
|
const rel = relative(pluginRoot, full);
|
|
1056
446
|
if (rel === join('.orchestrator', 'audits') || rel.startsWith(join('.orchestrator', 'audits') + sep)) continue;
|
|
@@ -1069,89 +459,37 @@ function walkDir(dir, acc = []) {
|
|
|
1069
459
|
return acc;
|
|
1070
460
|
}
|
|
1071
461
|
|
|
1072
|
-
// ---------------------------------------------------------------------------
|
|
1073
|
-
// Allowlist helpers (spec A.4)
|
|
1074
|
-
// ---------------------------------------------------------------------------
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* Return true if this gotzendorfer.at hit is covered by the exclusion allowlist.
|
|
1078
|
-
* @param {string} relPath - path relative to pluginRoot (forward slashes)
|
|
1079
|
-
* @param {string} line - the raw line content
|
|
1080
|
-
* @returns {boolean}
|
|
1081
|
-
*/
|
|
1082
462
|
function isAllowlisted(relPath, line) {
|
|
1083
|
-
|
|
463
|
+
const {policy, CP7_G} = currentOwnerRules();
|
|
1084
464
|
const norm = relPath.replace(/\\/g, '/');
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
return true;
|
|
1092
|
-
}
|
|
1093
|
-
// Any other gotzendorfer.at in this file is NOT excluded
|
|
1094
|
-
return false;
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
// A.4 exclusion 1: lines with office@ or security@ gotzendorfer.at and NO other gotzendorfer.at token
|
|
1098
|
-
// (i.e., the only gotzendorfer.at occurrence is an email address — count occurrences)
|
|
1099
|
-
const SANCTIONED_EMAILS = /(?:office|security)@gotzendorfer\.at/g;
|
|
1100
|
-
const allGotzTokens = [...line.matchAll(/gotzendorfer\.at/g)];
|
|
1101
|
-
const sanctionedMatches = [...line.matchAll(SANCTIONED_EMAILS)];
|
|
1102
|
-
if (allGotzTokens.length > 0 && allGotzTokens.length === sanctionedMatches.length) {
|
|
1103
|
-
// All gotzendorfer.at occurrences are the sanctioned email addresses
|
|
1104
|
-
return true;
|
|
465
|
+
// Only published attribution files permit public URLs; email contacts work everywhere.
|
|
466
|
+
const urlsAllowed = new Set(['README.md', '.claude-plugin/plugin.json', '.claude-plugin/marketplace.json', '.codex-plugin/plugin.json', '.cursor-plugin/plugin.json', 'site/index.html', 'site/de/index.html', 'site/impressum/index.html', 'site/datenschutz/index.html']).has(norm);
|
|
467
|
+
let residue = line;
|
|
468
|
+
for (const email of policy.publicEmails) residue = residue.replace(new RegExp(escapeRegex(email) + '(?![\\w-]|\\.[\\w])', 'g'), '');
|
|
469
|
+
if (urlsAllowed) {
|
|
470
|
+
for (const url of policy.publicUrls) residue = residue.replace(new RegExp(escapeRegex(url) + '(?![\\w-]|\\.[\\w])', 'g'), '');
|
|
1105
471
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
'.claude-plugin/marketplace.json',
|
|
1112
|
-
'.codex-plugin/plugin.json',
|
|
1113
|
-
// A.4 exclusion 7 (#1076): the three PUBLISHED static pages (vercel.json
|
|
1114
|
-
// outputDirectory: "site"). Rule-based, never file-based — membership here only
|
|
1115
|
-
// buys the line-form test below, and isAllowlisted() is consulted by CP3 and CP7
|
|
1116
|
-
// ONLY. A SELF_EXCLUSIONS entry would instead have switched off all eleven rules
|
|
1117
|
-
// for these files; that broad form is deliberately NOT used.
|
|
1118
|
-
'site/index.html',
|
|
1119
|
-
'site/impressum/index.html',
|
|
1120
|
-
'site/datenschutz/index.html',
|
|
1121
|
-
// The German landing page (2026-09-07 redesign) carries the same publisher
|
|
1122
|
-
// identity (JSON-LD @id + footer rel="author") as site/index.html. Exact
|
|
1123
|
-
// path, never a site/de/ prefix — site/guide/index.html stays outside.
|
|
1124
|
-
'site/de/index.html',
|
|
1125
|
-
]);
|
|
1126
|
-
const inAllowlistedFile = ALLOWLISTED_URL_PATHS.has(norm);
|
|
1127
|
-
|
|
1128
|
-
if (inAllowlistedFile) {
|
|
1129
|
-
// Check that the only gotzendorfer.at occurrences on this line are sanctioned URLs or emails
|
|
1130
|
-
const SANCTIONED_URL = /https?:\/\/gotzendorfer\.at\b/g;
|
|
1131
|
-
// The published site uses the www. host throughout, which SANCTIONED_URL cannot
|
|
1132
|
-
// match — it requires the bare domain IMMEDIATELY after the scheme. Without this
|
|
1133
|
-
// second form the three site entries above would exclude NOTHING and the gate
|
|
1134
|
-
// (including .husky/pre-commit) would be permanently red. The two forms are
|
|
1135
|
-
// DISJOINT: the www. prefix is mandatory here and impossible there, so no line
|
|
1136
|
-
// can be counted twice (README.md stays at 1 token / 1 sanctioned match).
|
|
1137
|
-
const SANCTIONED_PUBLIC_SITE = /(?:https?:\/\/)?www\.gotzendorfer\.at\b/g;
|
|
1138
|
-
const allGotzOnLine = [...line.matchAll(/gotzendorfer\.at/g)];
|
|
1139
|
-
const sanctionedUrlMatches = [...line.matchAll(SANCTIONED_URL)];
|
|
1140
|
-
const sanctionedSiteMatches = [...line.matchAll(SANCTIONED_PUBLIC_SITE)];
|
|
1141
|
-
const emailMatches = [...line.matchAll(SANCTIONED_EMAILS)];
|
|
1142
|
-
const totalSanctioned =
|
|
1143
|
-
sanctionedUrlMatches.length + sanctionedSiteMatches.length + emailMatches.length;
|
|
1144
|
-
if (allGotzOnLine.length > 0 && allGotzOnLine.length === totalSanctioned) {
|
|
1145
|
-
return true;
|
|
472
|
+
// Preserve the single documented contract comment; no arbitrary line exemption.
|
|
473
|
+
if (norm === 'tests/lib/events-default-url.test.mjs') {
|
|
474
|
+
for (const host of policy.eventsHosts) {
|
|
475
|
+
const contract = ' * - No literal `' + host + '` URL appears anywhere in scripts/ or hooks/.';
|
|
476
|
+
if (line === contract) return true;
|
|
1146
477
|
}
|
|
1147
478
|
}
|
|
1148
|
-
|
|
1149
|
-
return false;
|
|
479
|
+
return countMatches(CP7_G, line) > 0 && countMatches(CP7_G, residue) === 0;
|
|
1150
480
|
}
|
|
1151
481
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
482
|
+
function safeDiagnosticPath(value, confidentialPatterns = []) {
|
|
483
|
+
const rules = currentOwnerRules();
|
|
484
|
+
const patterns = [...confidentialPatterns, ...rules.redactionPatterns];
|
|
485
|
+
// Replace whole offending filename segments, retaining only safe location context.
|
|
486
|
+
return value.split('/').map((segment) => {
|
|
487
|
+
const forms = [segment, canonicalizeLine(segment, {preserveDashes:true}), canonicalizeLine(segment)];
|
|
488
|
+
const privateSegment = forms.some((form) => isOwnerLeakySegment(form) ||
|
|
489
|
+
patterns.some((re) => { re.lastIndex = 0; return re.test(form); }));
|
|
490
|
+
return privateSegment ? '[REDACTED]' : segment;
|
|
491
|
+
}).join('/');
|
|
492
|
+
}
|
|
1155
493
|
|
|
1156
494
|
let passed = 0;
|
|
1157
495
|
let failed = 0;
|
|
@@ -1166,73 +504,40 @@ function fail(msg) {
|
|
|
1166
504
|
failed++;
|
|
1167
505
|
}
|
|
1168
506
|
|
|
1169
|
-
// ---------------------------------------------------------------------------
|
|
1170
|
-
// Main check — only runs when invoked as the CLI entry point (#661). When this
|
|
1171
|
-
// module is imported (for unit-testing the canonicalization helpers), the scan
|
|
1172
|
-
// and process.exit() below are skipped.
|
|
1173
|
-
// ---------------------------------------------------------------------------
|
|
1174
|
-
|
|
1175
507
|
async function runScan() {
|
|
1176
508
|
console.log('--- Check 11: owner-privacy leakage ---');
|
|
1177
509
|
|
|
1178
510
|
if (!existsSync(pluginRoot)) {
|
|
1179
|
-
fail(
|
|
511
|
+
fail('plugin root does not exist');
|
|
1180
512
|
console.log('');
|
|
1181
513
|
console.log(`Results: ${passed} passed, ${failed} failed`);
|
|
1182
514
|
process.exit(1);
|
|
1183
515
|
}
|
|
1184
516
|
|
|
1185
|
-
const
|
|
1186
|
-
|
|
517
|
+
const owner = inspectOwnerPatterns();
|
|
518
|
+
if (owner.status !== 'ok') {
|
|
519
|
+
console.error(`WARN owner-patterns: ${owner.status} (${owner.reason ?? 'source-unavailable'}); host-specific rules unavailable`);
|
|
520
|
+
if (owner.status !== 'missing' || requireOwnerPatterns) fail('<scan-wide> — CP1 (guard disabled): owner patterns unavailable');
|
|
521
|
+
}
|
|
522
|
+
const { CP2, CP3, CP4, CP5_WITH_GOTZ, CP5_EXPORT, CP6_PATTERNS, CP7, CP3_G, CP7_G, CP10_PATTERNS, policy } = currentOwnerRules();
|
|
523
|
+
const allFiles = packedInventoryPath ? getPackedFiles() : getTrackedFiles();
|
|
524
|
+
const textFiles = packedInventoryPath ? allFiles : allFiles.filter(isTextFile);
|
|
1187
525
|
|
|
1188
|
-
// Exclusions:
|
|
1189
|
-
// - .orchestrator/audits/** never scanned (A.2/A.4-5)
|
|
1190
|
-
// - This guard's own source file (pattern-doc-comments define the scanner — not leaks).
|
|
1191
|
-
// - This guard's own test file (string-literal fixtures exercise the detector — not leaks).
|
|
1192
|
-
// Self-exclusions are the design-time fix for the latent bug exposed when scanner
|
|
1193
|
-
// fixture files transition from untracked → tracked in the same commit that tightens
|
|
1194
|
-
// detection (commit a68e94f for the original two; commit 95c8237 deep-3 W4 added the
|
|
1195
|
-
// case-insensitive P6 regex + introduced content-lint.test.mjs in the same commit,
|
|
1196
|
-
// producing a pre-commit false-pass — see pipeline #4365 / housekeeping-2 2026-05-19).
|
|
1197
526
|
const SELF_EXCLUSIONS = new Set([
|
|
1198
|
-
'scripts/lib/validate/check-owner-leakage.mjs',
|
|
1199
527
|
'tests/lib/validate/check-owner-leakage.test.mjs',
|
|
1200
|
-
// content-lint.test.mjs entry removed with the file itself (#985 Tier A,
|
|
1201
|
-
// 2026-08-05) — a dangling exclusion would pre-authorize any future file
|
|
1202
|
-
// at that path to bypass this scanner.
|
|
1203
528
|
'tests/husky/pre-commit-owner-leakage.test.mjs',
|
|
1204
|
-
// #634: encoding-contract fixtures (`-Users-bernhardg-` expected-value literals
|
|
1205
|
-
// are load-bearing for the resolveMemoryDir() assertions; P9 would self-flag them)
|
|
1206
529
|
'tests/lib/memory-paths.test.mjs',
|
|
1207
|
-
// #660: owner-leakage redaction-test fixtures — namespace.test.mjs feeds the real
|
|
1208
|
-
// CP1/CP6/CP10 leak literals into resolveRepoNamespace to prove they redact to
|
|
1209
|
-
// 'redacted-repo'; the assertion literals are fixtures, not leaks (same class as
|
|
1210
|
-
// check-owner-leakage.test.mjs above).
|
|
1211
530
|
'tests/lib/vault-mirror/namespace.test.mjs',
|
|
1212
|
-
// #700: vault-relocation classifier redaction-test fixtures — both files feed the
|
|
1213
|
-
// real CP6 private slugs (BuchhaltGenie / aiat-pmo-module) into the classifier to
|
|
1214
|
-
// prove they redact to 'redacted-repo'; assertion literals are fixtures, not leaks
|
|
1215
|
-
// (same class as namespace.test.mjs above).
|
|
1216
531
|
'tests/lib/vault-relocation-rules.test.mjs',
|
|
1217
532
|
'tests/scripts/relocate-vault-corpus.test.mjs',
|
|
1218
533
|
]);
|
|
1219
534
|
const scanFiles = textFiles.filter((f) => {
|
|
1220
535
|
const rel = relative(pluginRoot, f).replace(/\\/g, '/');
|
|
1221
|
-
if (rel.startsWith('.orchestrator/audits/')) return false;
|
|
1222
|
-
if (SELF_EXCLUSIONS.has(rel)) return false;
|
|
536
|
+
if (!packedInventoryPath && rel.startsWith('.orchestrator/audits/')) return false;
|
|
537
|
+
if (!packedInventoryPath && SELF_EXCLUSIONS.has(rel)) return false;
|
|
1223
538
|
return true;
|
|
1224
539
|
});
|
|
1225
540
|
|
|
1226
|
-
// CP11 (#728a): load the host-local confidential-names patterns ONCE per scan.
|
|
1227
|
-
// [] when unconfigured (the default) OR when the host-local helper modules are
|
|
1228
|
-
// unresolvable (standalone single-file copy) → the CP11 block below is a no-op.
|
|
1229
|
-
// Awaited once, before the per-line loop, because the helpers are now dynamically
|
|
1230
|
-
// imported (standalone-safe) — CP1–CP10 behaviour is unchanged.
|
|
1231
|
-
//
|
|
1232
|
-
// #1244: a CP11 that was EXPECTED but could not load is a DISABLED guard, not a
|
|
1233
|
-
// clean scan — it is announced on stderr and counted as a FAIL so the run exits
|
|
1234
|
-
// non-zero. CP1–CP10 still run to completion either way: a disabled CP11 must not
|
|
1235
|
-
// suppress the findings the other ten rules can still make.
|
|
1236
541
|
const cp11 = await getConfidentialNamePatterns();
|
|
1237
542
|
const cp11Patterns = cp11.patterns;
|
|
1238
543
|
if (cp11.inertWarn) {
|
|
@@ -1240,22 +545,24 @@ if (cp11.inertWarn) {
|
|
|
1240
545
|
}
|
|
1241
546
|
if (cp11.disabledReason) {
|
|
1242
547
|
console.error(`CP11 DISABLED: ${cp11.disabledReason}`);
|
|
1243
|
-
// Shaped like the per-file FAIL lines (`<where> — <CPn label>: <content>`) so the
|
|
1244
|
-
// report's `— CPn` attribution parser sees CP11 here too; `<scan-wide>` stands in
|
|
1245
|
-
// for the file position because a disabled guard is a property of the RUN, not of
|
|
1246
|
-
// any one file. The reason carries no path (see getConfidentialNamePatterns).
|
|
1247
548
|
fail(`<scan-wide> — CP11 (guard disabled): ${cp11.disabledReason}`);
|
|
1248
549
|
}
|
|
1249
550
|
|
|
1250
|
-
/** @type {Array<{relPath: string, lineNum: number, pattern: string, lineContent: string}>} */
|
|
1251
551
|
const violations = [];
|
|
1252
552
|
|
|
1253
553
|
for (const filePath of scanFiles) {
|
|
1254
554
|
let content;
|
|
1255
555
|
try {
|
|
1256
|
-
|
|
556
|
+
if (packedInventoryPath) {
|
|
557
|
+
const fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
558
|
+
try {
|
|
559
|
+
if (!fstatSync(fd).isFile() || realpathSync(filePath) !== filePath) throw new Error('unsafe-inventory');
|
|
560
|
+
content = readFileSync(fd, 'utf8');
|
|
561
|
+
} finally { closeSync(fd); }
|
|
562
|
+
} else { content = readFileSync(filePath, 'utf8'); }
|
|
1257
563
|
} catch {
|
|
1258
|
-
|
|
564
|
+
fail(`${safeDiagnosticPath(relative(pluginRoot, filePath), cp11Patterns)} — file unreadable`);
|
|
565
|
+
continue;
|
|
1259
566
|
}
|
|
1260
567
|
|
|
1261
568
|
const relPath = relative(pluginRoot, filePath).replace(/\\/g, '/');
|
|
@@ -1264,103 +571,53 @@ for (const filePath of scanFiles) {
|
|
|
1264
571
|
lines.forEach((line, idx) => {
|
|
1265
572
|
const lineNum = idx + 1;
|
|
1266
573
|
|
|
1267
|
-
// The canonical form of this line, computed ONCE (#1080 Finding A). Until now
|
|
1268
|
-
// matchOwnerPath (CP1) was the ONLY consumer of canonicalizeLine; every other rule
|
|
1269
|
-
// tested the raw line, so an entity-encoded private host inside an href resolved in
|
|
1270
|
-
// the browser and reported nothing here. The four DOT-anchored rules (CP2, CP3, CP7,
|
|
1271
|
-
// CP8) are re-tested against it below.
|
|
1272
|
-
//
|
|
1273
|
-
// Why exactly those four, and not every rule — the discriminator is which character
|
|
1274
|
-
// the rule ANCHORS on, and it was measured, not assumed:
|
|
1275
|
-
// - Canonicalization can FABRICATE a '/' out of any benign hyphen run
|
|
1276
|
-
// (`@goetzendorfer-team` -> `@goetzendorfer/team`, `~-Projects-Bernhard` ->
|
|
1277
|
-
// `~/Projects/Bernhard`; both flip false -> true under canon). So the
|
|
1278
|
-
// SLASH-anchored rules CP4 and CP10 would gain folding artifacts, and stay raw.
|
|
1279
|
-
// - It can never fabricate a '.' from a separator: `gitlab-gotzendorfer-at`
|
|
1280
|
-
// canonicalizes to `gitlab/gotzendorfer/at`, dot-free. A '.' appears in the
|
|
1281
|
-
// canonical form ONLY by decoding an escape — which is the evasion itself. So for
|
|
1282
|
-
// the DOT-anchored rules (CP2, CP3, CP7, CP8) every canonical-only hit is a
|
|
1283
|
-
// decoded evasion by construction, and the false-positive surface is empty.
|
|
1284
|
-
// - CP6 stays raw for the mirror-image reason: it anchors on `\b`-delimited slug
|
|
1285
|
-
// literals, and the dash folding SHREDS five of the seven slugs
|
|
1286
|
-
// (`mail-assistant` -> `mail/assistant`, which `\bmail-assistant\b` no longer
|
|
1287
|
-
// matches), so a canonical test there is a no-op at best.
|
|
1288
|
-
// - CP5 anchors on an identifier and CP11 on host-local names; neither is a path or
|
|
1289
|
-
// host spelling, so neither is re-tested.
|
|
1290
|
-
// Repo-wide cost of the four, measured over 1547 tracked files before landing: 0 new
|
|
1291
|
-
// findings.
|
|
1292
574
|
const canon = canonicalizeLine(line);
|
|
1293
575
|
|
|
1294
|
-
// CP1: personal home path — CANONICALIZED match (#661). One structural rule
|
|
1295
|
-
// replaces the slash-form (P1, #631) + dash-encoded (P9, #634) treadmill and
|
|
1296
|
-
// catches percent/unicode/backslash/homoglyph encodings of the same path.
|
|
1297
576
|
const ownerPathHit = matchOwnerPath(line);
|
|
1298
577
|
if (ownerPathHit) {
|
|
1299
578
|
violations.push({ relPath, lineNum, pattern: ownerPathHit, lineContent: line.trim() });
|
|
1300
579
|
}
|
|
1301
580
|
|
|
1302
|
-
// CP2: private GitLab host — raw OR canonical (#1080). ONE `if` per rule, so a
|
|
1303
|
-
// line matching BOTH forms still reports exactly ONE violation. (The pre-existing
|
|
1304
|
-
// 'CP2: private GitLab host' scan row, which expects fails: 2 for the CP2+CP7 pair,
|
|
1305
|
-
// is the standing guard against a double-count regression here.) CP2 consults no
|
|
1306
|
-
// allowlist, so the raw/canonical split CP3 and CP7 need does not arise.
|
|
1307
581
|
if (CP2.test(line) || CP2.test(canon)) {
|
|
1308
|
-
violations.push({ relPath, lineNum, pattern: 'CP2 (
|
|
582
|
+
violations.push({ relPath, lineNum, pattern: 'CP2 (private host)', lineContent: line.trim() });
|
|
1309
583
|
}
|
|
1310
584
|
|
|
1311
|
-
// CP3: private events domain — the raw form consults the exclusion allowlist
|
|
1312
|
-
// exactly as before; a DECODED surplus occurrence (canonical count > raw count)
|
|
1313
|
-
// bypasses it, because an encoded spelling is never the sanctioned publication.
|
|
1314
|
-
// See CP3_G / CP7_G for why this is a count and not isAllowlisted(relPath, canon).
|
|
1315
585
|
const cp3Decoded = countMatches(CP3_G, canon) > countMatches(CP3_G, line);
|
|
1316
586
|
if (CP3.test(line) || cp3Decoded) {
|
|
1317
587
|
if (cp3Decoded || !isAllowlisted(relPath, line)) {
|
|
1318
|
-
violations.push({ relPath, lineNum, pattern: 'CP3 (events
|
|
588
|
+
violations.push({ relPath, lineNum, pattern: 'CP3 (private events host)', lineContent: line.trim() });
|
|
1319
589
|
}
|
|
1320
590
|
}
|
|
1321
591
|
|
|
1322
|
-
// CP4: private package scope
|
|
1323
592
|
if (CP4.test(line)) {
|
|
1324
|
-
violations.push({ relPath, lineNum, pattern: 'CP4 (
|
|
593
|
+
violations.push({ relPath, lineNum, pattern: 'CP4 (private scope/ scope)', lineContent: line.trim() });
|
|
1325
594
|
}
|
|
1326
595
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
violations.push({ relPath, lineNum, pattern: 'CP5 (DEFAULT_GITLAB_HOST on gotzendorfer line)', lineContent: line.trim() });
|
|
596
|
+
if (CP5_WITH_GOTZ.test(line) && policy.privateDomains.some((token) => line.includes(token.split('.')[0]))) {
|
|
597
|
+
violations.push({ relPath, lineNum, pattern: 'CP5 (DEFAULT_GITLAB_HOST on owner line)', lineContent: line.trim() });
|
|
1330
598
|
} else if (CP5_EXPORT.test(line)) {
|
|
1331
599
|
violations.push({ relPath, lineNum, pattern: 'CP5 (DEFAULT_GITLAB_HOST exported const)', lineContent: line.trim() });
|
|
1332
600
|
}
|
|
1333
601
|
|
|
1334
|
-
// CP6: private project slugs
|
|
1335
602
|
for (let i = 0; i < CP6_PATTERNS.length; i++) {
|
|
1336
603
|
if (CP6_PATTERNS[i].test(line)) {
|
|
1337
|
-
violations.push({ relPath, lineNum, pattern:
|
|
604
|
+
violations.push({ relPath, lineNum, pattern: 'CP6 (private slug)', lineContent: line.trim() });
|
|
1338
605
|
break; // one violation per line per slug is enough
|
|
1339
606
|
}
|
|
1340
607
|
}
|
|
1341
608
|
|
|
1342
|
-
// CP7: catch-all gotzendorfer.at — same split as CP3: the raw form consults the
|
|
1343
|
-
// exclusion allowlist, a decoded surplus occurrence bypasses it. This is the arm
|
|
1344
|
-
// that catches the sharpest shape of #1080 Finding A — a WORKING link on a published
|
|
1345
|
-
// page, `<a href="https://gitlab.gotzendorfer.at/...">`, which the browser
|
|
1346
|
-
// resolves and the raw-only rule read as ordinary text.
|
|
1347
609
|
const cp7Decoded = countMatches(CP7_G, canon) > countMatches(CP7_G, line);
|
|
1348
610
|
if (CP7.test(line) || cp7Decoded) {
|
|
1349
611
|
if (cp7Decoded || !isAllowlisted(relPath, line)) {
|
|
1350
|
-
violations.push({ relPath, lineNum, pattern: 'CP7 (
|
|
612
|
+
violations.push({ relPath, lineNum, pattern: 'CP7 (private domain catch-all)', lineContent: line.trim() });
|
|
1351
613
|
}
|
|
1352
614
|
}
|
|
1353
615
|
|
|
1354
|
-
|
|
1355
|
-
// exempt). Raw OR canonical: `10.11.12.13` decodes to a literal quad.
|
|
1356
|
-
// The allowlist here is PATH-scoped, not line-scoped, so it needs no raw/canonical
|
|
1357
|
-
// split — it applies identically to both forms.
|
|
1358
|
-
if ((CP8.test(line) || CP8.test(canon)) && !CP8_ALLOWLIST.has(relPath)) {
|
|
616
|
+
if ((CP8.test(line) || CP8.test(canon)) && (packedInventoryPath || !CP8_ALLOWLIST.has(relPath))) {
|
|
1359
617
|
violations.push({ relPath, lineNum, pattern: 'CP8 (RFC1918 private IP)', lineContent: line.trim() });
|
|
1360
618
|
}
|
|
1361
619
|
|
|
1362
|
-
|
|
1363
|
-
if (!CP10_ALLOWLIST.has(relPath)) {
|
|
620
|
+
if (packedInventoryPath || !CP10_ALLOWLIST.has(relPath)) {
|
|
1364
621
|
for (const re of CP10_PATTERNS) {
|
|
1365
622
|
if (re.test(line)) {
|
|
1366
623
|
violations.push({ relPath, lineNum, pattern: 'CP10 (~/Projects/<name>/ personal segment)', lineContent: line.trim() });
|
|
@@ -1369,28 +626,13 @@ for (const filePath of scanFiles) {
|
|
|
1369
626
|
}
|
|
1370
627
|
}
|
|
1371
628
|
|
|
1372
|
-
// CP11: host-local confidential customer/repo names (#728a). INACTIVE unless a
|
|
1373
|
-
// names file is configured (cp11Patterns is [] by default).
|
|
1374
|
-
//
|
|
1375
|
-
// DETECTION ONLY here — the confidential-name REDACTION is applied at the print
|
|
1376
|
-
// choke-point (redactSpans over EVERY violation's lineContent), so a name that
|
|
1377
|
-
// rides in on a CP1–CP10 hit on the same line is scrubbed too (Fix 1), and a
|
|
1378
|
-
// name that is a prefix of another configured name cannot leak a suffix residue
|
|
1379
|
-
// (Fix 2). cp11Patterns are non-global, so `.test()` is stateless.
|
|
1380
629
|
if (cp11Patterns.length > 0 && cp11Patterns.some((re) => re.test(line))) {
|
|
1381
630
|
violations.push({ relPath, lineNum, pattern: 'CP11 (confidential name)', lineContent: line.trim() });
|
|
1382
631
|
}
|
|
1383
632
|
});
|
|
1384
633
|
}
|
|
1385
634
|
|
|
1386
|
-
// Deduplicate: CP7 and CP3 can overlap — de-dup by (relPath, lineNum, pattern)
|
|
1387
|
-
// But CP3 and CP7 are distinct patterns so they'd produce separate entries.
|
|
1388
|
-
// However, one line could match both CP3 and CP7 — treat as two violations.
|
|
1389
|
-
// The spec does not say to deduplicate, so keep as-is.
|
|
1390
|
-
|
|
1391
635
|
if (violations.length === 0) {
|
|
1392
|
-
// #1244: only claim the clean verdict the run actually earned. With CP11
|
|
1393
|
-
// disabled, ten of eleven rules ran — say that instead of "no leakage found".
|
|
1394
636
|
if (cp11.disabledReason) {
|
|
1395
637
|
console.log(` (CP1–CP10 found no leakage across ${scanFiles.length} scanned files; CP11 did not run)`);
|
|
1396
638
|
} else {
|
|
@@ -1398,15 +640,8 @@ if (violations.length === 0) {
|
|
|
1398
640
|
}
|
|
1399
641
|
} else {
|
|
1400
642
|
for (const v of violations) {
|
|
1401
|
-
//
|
|
1402
|
-
|
|
1403
|
-
// reaches stdout (the public GitHub-Actions log). redactSpans is order-
|
|
1404
|
-
// independent (span-merge, never chained .replace), and is a no-op when no
|
|
1405
|
-
// names file is configured (cp11Patterns === []), so the CP1–CP10 default-path
|
|
1406
|
-
// output is byte-identical to before. Redact first, THEN truncate, so a name
|
|
1407
|
-
// straddling the 120-char boundary can never leak its tail.
|
|
1408
|
-
const safeContent = redactSpans(v.lineContent, cp11Patterns);
|
|
1409
|
-
fail(`${v.relPath}:${v.lineNum} — ${v.pattern}: ${safeContent.slice(0, 120)}`);
|
|
643
|
+
// Violation content is omitted: canonical detection may hide reversible spellings.
|
|
644
|
+
fail(`${safeDiagnosticPath(v.relPath, cp11Patterns)}:${v.lineNum} — ${v.pattern}: [REDACTED]`);
|
|
1410
645
|
}
|
|
1411
646
|
}
|
|
1412
647
|
|
|
@@ -1415,71 +650,19 @@ console.log(`Results: ${passed} passed, ${failed} failed (${scanFiles.length} sc
|
|
|
1415
650
|
process.exit(failed === 0 ? 0 : 1);
|
|
1416
651
|
}
|
|
1417
652
|
|
|
1418
|
-
// ---------------------------------------------------------------------------
|
|
1419
|
-
// Exported helper for in-process leak detection (Issue #660 namespace guard)
|
|
1420
|
-
// ---------------------------------------------------------------------------
|
|
1421
|
-
|
|
1422
|
-
/**
|
|
1423
|
-
* Test whether a single value (a repo identifier or sanitised slug segment)
|
|
1424
|
-
* matches any owner-privacy leakage pattern.
|
|
1425
|
-
*
|
|
1426
|
-
* Runs the canonical CP1 check (via canonicalizeLine + CP1_CANON) and the
|
|
1427
|
-
* word-boundary CP6 (private project slugs) and CP10 (personal name in a
|
|
1428
|
-
* Projects path) checks against the raw value. Returns the matched pattern id
|
|
1429
|
-
* string or null when clean.
|
|
1430
|
-
*
|
|
1431
|
-
* CP6 here uses the NARROWER CP6_INPROCESS_PATTERNS (PRIVATE_SLUGS minus
|
|
1432
|
-
* VAULT_CLEAR_SLUGS — issue #59 owner decision 2026-07-18), so carved-out slugs
|
|
1433
|
-
* (buchhaltgenie, mail-assistant, wien-forschungsfragen-klima, launchpad-ai-factory,
|
|
1434
|
-
* angebotschecker) resolve to their own vault namespace here while the tracked-file
|
|
1435
|
-
* scanner (runScan, full CP6_PATTERNS) still blocks them from the public mirror.
|
|
1436
|
-
*
|
|
1437
|
-
* This is the single source of truth for in-process leak detection — do NOT
|
|
1438
|
-
* reimplement these pattern checks outside this module. The `namespace.mjs`
|
|
1439
|
-
* resolver uses this to guard vault path segments before any filesystem write.
|
|
1440
|
-
*
|
|
1441
|
-
* @param {string} value — raw repo identifier or sanitised slug to test.
|
|
1442
|
-
* @returns {'CP1'|'CP6'|'CP10'|null}
|
|
1443
|
-
*/
|
|
1444
653
|
export function isOwnerLeakySegment(value) {
|
|
1445
654
|
if (typeof value !== 'string' || !value) return null;
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
if (
|
|
1450
|
-
|
|
1451
|
-
// CP1 (bare): the personal-username token standing alone — e.g. the macOS login
|
|
1452
|
-
// name surfacing via deriveRepo()'s basename(process.cwd()) fallback when cwd is
|
|
1453
|
-
// the home directory. CP1_CANON only fires with a `/Users/` prefix, so the bare
|
|
1454
|
-
// segment would otherwise slip through into a committed vault path (#660 Q3-LOW-1).
|
|
1455
|
-
if (/^[bB][eE][rR][nN][hH][aA][rR][dD][gG][a-z]*$/.test(value)) return 'CP1';
|
|
1456
|
-
|
|
1457
|
-
// CP6: private project slugs (word-boundary, case-insensitive). In-process
|
|
1458
|
-
// guard uses the VAULT_CLEAR_SLUGS-carved list, NOT the full CP6_PATTERNS the
|
|
1459
|
-
// tracked-file scanner uses (issue #59).
|
|
1460
|
-
for (const re of CP6_INPROCESS_PATTERNS) {
|
|
1461
|
-
if (re.test(value)) return 'CP6';
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
// CP10: personal name in a ~/Projects/<name>/ path.
|
|
1465
|
-
for (const re of CP10_PATTERNS) {
|
|
1466
|
-
if (re.test(value)) return 'CP10';
|
|
1467
|
-
}
|
|
1468
|
-
|
|
655
|
+
const rules = currentOwnerRules();
|
|
656
|
+
if (rules.status !== 'ok' && (rules.status !== 'missing' || (process.env.SO_OWNER_PATTERNS_FILE || '').trim())) return 'CP1';
|
|
657
|
+
if (matchOwnerPath(value) || rules.CP1_BARE.test(value)) return 'CP1';
|
|
658
|
+
if (rules.CP6_INPROCESS_PATTERNS.some((re) => re.test(value))) return 'CP6';
|
|
659
|
+
if (rules.CP10_PATTERNS.some((re) => re.test(value))) return 'CP10';
|
|
1469
660
|
return null;
|
|
1470
661
|
}
|
|
1471
662
|
|
|
1472
|
-
// Run the scan only when invoked directly as a CLI (#661).
|
|
1473
663
|
if (isMain) {
|
|
1474
|
-
// runScan is async (the CP11 confidential-names helpers are now dynamically
|
|
1475
|
-
// imported, standalone-safe) and self-terminates via process.exit(). We do NOT
|
|
1476
|
-
// await it — that would make this a top-level-await module and force every static
|
|
1477
|
-
// importer of isOwnerLeakySegment to become async. Instead we attach a `.catch`
|
|
1478
|
-
// (Fix 4): while runScan's sole async step swallows its own errors today, an
|
|
1479
|
-
// unhandled rejection from ANY future refactor must fail CLOSED with context and a
|
|
1480
|
-
// deterministic exit 1 — never a silent unhandled-rejection warning + exit 0.
|
|
1481
664
|
runScan().catch((err) => {
|
|
1482
|
-
console.error('check-owner-leakage
|
|
665
|
+
console.error('check-owner-leakage failed:', (['ENOENT','EACCES','EPERM','ELOOP','ENOTDIR','EISDIR'].includes(err?.code) ? err.code : ['unsafe-inventory','invalid-inventory','inventory-too-large','invalid-inventory-entry'].includes(err?.message) ? err.message : 'scan-error'));
|
|
1483
666
|
process.exit(1);
|
|
1484
667
|
});
|
|
1485
668
|
}
|