Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/fox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ Stack:
The masking keys: domains that never hash alike, and rotating a key (Enterprise)
mask key use <domain> --table [schema.]table [--branch b] [--yes]
Put a table's masks under a key domain (Enterprise)
mask unmask <email> --branch b [--off] [--yes]
Let a masked account see real values on a branch, every read audited (Enterprise)
mask lookups [--branch b] [--limit n] [--json]
Who looked a person up, when, by which column, how many found: never the value
access review [--dormant 90] [--json] [--csv accounts|keys|branches|applications]
Who can reach what, with which keys, who may override the guardrails,
who has gone quiet, and the applications that assert their users (Enterprise)
Expand Down
79 changes: 79 additions & 0 deletions cmd/fox/maskcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import (
// fox mask key [status] [--json]
// fox mask key create|rotate|drop <domain> [--yes] (Enterprise)
// fox mask key use <domain> --table [schema.]table [--branch b] [--yes] (Enterprise)
// fox mask unmask <email> --branch b [--off] [--yes] (Enterprise)
// fox mask lookups [--branch b] [--limit n] [--json]
//
// Reading where masking stands is in every edition; setting it is Enterprise.
// Each setup, rule and account change is in the security log.
Expand All @@ -43,6 +45,12 @@ func maskCmd(args []string) {
case "key":
maskKey(args[1:])
return
case "unmask":
maskUnmask(args[1:])
return
case "lookups":
maskLookups(args[1:])
return
case "status":
args = args[1:]
}
Expand Down Expand Up @@ -105,6 +113,14 @@ func printMaskStatus(st branch.MaskStatus) {
if len(st.Accounts) == 0 {
fmt.Printf(" none: nobody is masked until an admin masks them\n")
}
fmt.Printf("\nUnmask on %s (%d): real values, every read of a marked table audited\n", st.Branch, len(st.Unmasks))
for _, g := range st.Unmasks {
fmt.Printf(" %-40s by %s at %s\n", g.Email, g.By, g.At)
}
if len(st.Unmasks) > 0 && !st.AuditOn {
fmt.Printf(" The audit log is off, so their reads are not being logged: %s audit-log on\n", brand.CLI)
}
fmt.Printf("\nLookups on %s: %d recorded (%s mask lookups --branch %s)\n", st.Branch, len(st.Lookups), brand.CLI, st.Branch)
}

// maskSetup sets masking up on a branch: the key if the install has none, the
Expand Down Expand Up @@ -420,3 +436,66 @@ func printMaskKeys(st branch.MaskKeysStatus) {
fmt.Printf("\nNot running, so not read: %s\n", strings.Join(st.Stopped, ", "))
}
}

// maskUnmask gives a masked account unmask on a branch, or takes it. There it
// sees real values, and every read it makes of a marked table is audited.
func maskUnmask(args []string) {
requireFeature(edition.Masking)
ps := positionals(args, "--branch")
b := optValue(args, "--branch")
if len(ps) != 1 || b == "" {
fmt.Fprintf(os.Stderr, "usage: %s mask unmask <email> --branch b [--off] [--yes]\n", brand.CLI)
os.Exit(2)
}
email, off := strings.ToLower(ps[0]), hasFlag(args, "--off")
if !hasFlag(args, "--yes") {
if off {
fmt.Printf("This takes unmask on %s from %s: they read masks there again.\n", b, email)
} else {
fmt.Printf("This lets %s see real values on %s, as its owner does.\n\n"+
"Every read they make there of a marked table is audited: their role's audit\n"+
"role becomes %s, which only a superuser can change, so the audit log\n"+
"records those reads, and theirs only. It is listed in the access review.\n",
email, b, branch.AuditReadRole)
if !branch.AuditOn() {
fmt.Printf("\nThe audit log is off, so their reads are not logged until `%s audit-log on`.\n", brand.CLI)
}
}
fmt.Printf("Their open sessions on %s end, so the next one sees the change.\n\nRun again with --yes to go ahead.\n", b)
return
}
must(branch.Unmask(email, b, cliActor(), off))
what := "given unmask on " + b
if off {
what = "no longer unmasked on " + b
}
recordMask(auth.EvMaskUnmask, b, email+" "+what)
fmt.Printf("Done: %s is %s.\n", email, what)
}

// maskLookups lists a branch's lookups: who, when, which column, how many
// rows found. Never the value looked up, which is not kept.
func maskLookups(args []string) {
b := maskBranch(args)
n := 100
if v := optValue(args, "--limit"); v != "" {
if _, err := fmt.Sscan(v, &n); err != nil {
must(fmt.Errorf("--limit is a number, not %q", v))
}
}
ls, err := branch.Lookups(b, n)
must(err)
if hasFlag(args, "--json") {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
must(enc.Encode(ls))
return
}
fmt.Printf("Lookups on %s (%d, newest first). The values looked up are not kept.\n", b, len(ls))
for _, l := range ls {
fmt.Printf(" %s %-36s %s.%s found %d\n", l.At, l.Who, l.Table, l.Column, l.Found)
}
if len(ls) == 0 {
fmt.Printf(" none: a masked account looks a person up with SELECT * FROM %s.find('table', 'column', 'value')\n", branch.MaskSchema)
}
}
9 changes: 5 additions & 4 deletions docs/FOX_Checklist.html
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ <h2><span class="al">H</span>Blackbox (record of schema changes)<span class="cnt
<li class="ok">Enterprise: confirming personal data (point 4.6) marks its table&rsquo;s writes; the tables confirmed and not audited named in every edition, and marked together with <code>table --personal-data</code></li>
<li class="ok">SECURITY DEFINER functions naming marked tables named as not audited, in <code>fox check</code>, <code>status</code> and the console</li>
<li class="ok">Enterprise: the evidence export for a period: coverage and limits as stated, each branch&rsquo;s marks and gaps, integrity and anchors, delivery, activity and every change; with the records and anchors to check it offline (step 6b)</li>
<li class="next">Reads audited for the logins that may unmask, once masking exists (point 7)</li></ul></td><td class="tb">Unit; f6 suite; §11y–§11af; UI</td></tr>
<li class="ok">Reads audited for the logins that may unmask: <code>fox mask unmask</code> (masking step 3, O3) sets the account&rsquo;s role&rsquo;s <code>pgaudit.role</code> to <code>bb_audit_read</code>, so its reads of marked tables are logged, and only its</li></ul></td><td class="tb">Unit; f6 suite; §11y–§11af; UI</td></tr>
<tr data-id="H22" data-st="part"><td class="c"><i class="ck part"></i></td><td class="id">H22</td><td><b>The hardening report</b> (point 3.2; FoxByte&rsquo;s own checks, not the CIS Benchmark)<ul class="subs">
<li class="ok">About 40 checks, basic and strict, across installation, files, logging, auditing, roles, connections, settings and backups: pass, fail, manual or n/a, each with what was found, why it matters and the fix</li>
<li class="ok">Where FoxByte differs on purpose (TLS at the Gateway, statement text kept out of the log, local trust inside the container), the check says so rather than failing</li>
Expand Down Expand Up @@ -767,15 +767,16 @@ <h2><span class="al">O</span>Personal data &amp; compliance<span class="cnt"></s
<li class="ok"><code>fox access review [--dormant] [--json] [--csv accounts|keys|branches|applications]</code>, <code>GET /api/access-review</code> (admins only), and the console&rsquo;s Access review page with CSV downloads and print for PDF</li>
<li class="ok">Each report in the security log; what it cannot claim stated in it</li>
<li class="ok">Applications: each registration, its account, its keys with age, state and what each asserted by branch, and findings to question; <code>--csv applications</code> (F1)</li>
<li class="lim">No unmask rights to review until masking (point 7); campaigns are 4.9 (Gate 2)</li></ul></td><td class="tb">enterprise/accessreview unit; VM &sect;11s; accessreview.spec.ts</td></tr>
<li class="ok">Sees unmasked: whether each account is an admin, not masked, or masked, with the branches it owns and where it holds unmask, its reads audited (masking step 3)</li>
<li class="lim">Campaigns are 4.9 (Gate 2)</li></ul></td><td class="tb">enterprise/accessreview unit; VM &sect;11s; accessreview.spec.ts</td></tr>
<tr data-id="O3" data-st="part"><td class="c"><i class="ck part"></i></td><td class="id">O3</td><td><b>Masking personal data</b> (point 4.6 and F5, Enterprise; <code>docs/FOX_F5_Masking_Design.md</code>)<ul class="subs">
<li class="ok">Gate 0 prototype (<code>make integration-f5</code>, 57/57 on PostgreSQL 18 and 16): the keyed hash in <code>bb_identity</code> 0.2, its key a file the server reads; a masked branch on a ZFS clone left with no real value in its files; the costs measured</li>
<li class="ok">Step 1: <code>fox mask setup|rule|account</code>, four API routes and the console&rsquo;s Masking page; rules (partial, keyed hash, fake, empty) only on confirmed findings, Aadhaar never hashed or faked; masks kept in shadow tables by statement triggers; masked accounts read masks and ordinary tables, never the real marked table, the hash or the key, and cannot write to marked tables; admins and a branch&rsquo;s owner see real values; the cost said before a rule is set</li>
<li class="ok">Step 2: <code>fox mask key [status|create|rotate|drop|use]</code>, two API routes and a Keys panel on the Masking page; key domains whose masks hash apart; a table&rsquo;s masks put under one; a key rotated on every running branch while masked readers read the old masks until each table is done and writes pause only for a moment, then the old key deleted; a rotation cut short carries on when run again; a branch stopped through a rotation named, and its masks made again by setup; a domain in use, or perhaps in use on a branch stopped since it was made, is not deleted</li>
<li class="todo">Step 3: unmask, the audited lookup, unmask rights in the access review, reads audited</li>
<li class="ok">Step 3: <code>fox mask unmask &lt;email&gt; --branch b</code> gives a masked account real values on one branch, every read it makes there of a marked table logged by the audit log through <code>bb_audit_read</code> (its role&rsquo;s <code>pgaudit.role</code>, a superuser&rsquo;s setting it cannot undo), which closes 3.1; the lookup, <code>bb_mask.find(table, column, value)</code>, finds a person by an exact value and returns the rows masked, 30 a minute for each account, each call recorded without the value (<code>fox mask lookups</code>); the access review&rsquo;s Sees unmasked column; a deleted branch&rsquo;s unmask goes with it; two API routes and the Masking page&rsquo;s Unmask and Lookups tables</li>
<li class="todo">Step 4: every access path tested (COPY, functions, EXPLAIN, statistics, logs)</li>
<li class="todo">Step 5: masked branches, closed until masked and verified</li>
<li class="lim">One-column primary keys only; a JSON path into an array, or a quoted key, is refused for now; masking stays on once set up; a masked account has no DDL rights; a domain&rsquo;s key is the install&rsquo;s, shared by every branch, so a branch stopped through a rotation keeps its old masks until set up again</li></ul></td><td class="tb">enterprise/masking unit; internal/branch mask_test.go, maskkey_test.go; VM &sect;11ai, &sect;11aj; masking.spec.ts</td></tr>
<li class="lim">One-column primary keys only; a JSON path into an array, or a quoted key, is refused for now; masking stays on once set up; a masked account has no DDL rights; a domain&rsquo;s key is the install&rsquo;s, shared by every branch, so a branch stopped through a rotation keeps its old masks until set up again; an unmask holder&rsquo;s reads are logged only while the audit log is on; the lookup is by a column with a rule of its own, not a JSON path</li></ul></td><td class="tb">enterprise/masking unit; internal/branch mask_test.go, maskkey_test.go; VM &sect;11ai, &sect;11aj, &sect;11ak; masking.spec.ts</td></tr>
</table>
<div class="foot">FOX_Checklist &middot; area O</div>
</section>
Expand Down
Binary file modified docs/FOX_Checklist.pdf
Binary file not shown.
Loading
Loading