Lesson 04 / 12
Storage Exposure
Two separate measures of a publicly open storage item: 7 of 13 object groups are open, with 61,697 records and 6 of 7 data types readable. Closing public access at the bucket level closes 9,260 personal records but leaves the spread radius at 10; closing the 47-record configuration item brings the radius down from 10 to 5 and stops 9 legitimate accesses a day. Closing all public access costs 8,655 accesses a day.
Published Updated
In this lesson
- Derives the effective permission from bucket- and object-level permissions and counts the publicly open item
- Measures the data type and record count readable from the open item as separate exposure figures
- Shows that the fix that lowers record volume and the fix that lowers the spread radius are not the same fix
Contents
The previous lesson treated the network path as a precondition: if the path was closed, permission did not matter. But some items have their outside path open on purpose — the subscriber portal’s asset store gets eight thousand six hundred legitimate outside accesses a day and cannot be closed. For that item the question is not the path, it is the permission set: which identity an incoming request is served with, and what a request that presents no identity at all can read.
This lesson scans the permission set of storage items. The Storage and Data in the Cloud course measured bucket access and signed links; the mechanics are not repeated here, only exposure is counted. Two separate figures come out, and the lesson’s finding is that they diverge: the records readable from an open item and the spread radius that starts from that item do not drop with the same fix.
- CM18. Permission is written at two levels: the bucket level and the object group level. If a value is written at the object level, that is the effective permission; if not, the bucket level is inherited.
- CM19. A publicly open item is one that a request presenting no identity at all can read or list. Signed access does not count as open in this lesson.
- CM20. For every object group, a data type, a record count, and how many legitimate public accesses it gets a day are recorded. The numbers are fictional.
- CM21. An object group can carry an identity reference: the file inside it contains a role’s name and the configuration that uses it. The credential itself is not modeled — only the reference’s presence and its consequence are counted.
- CM22. The exposure radius is the union of two components: buckets read without an identity, and the items reached by the walk that starts from an identity reference in an open file.
The Permission Set and the Open Item
// cloud/storage.mjs — object storage permission set and access graph MODEL. Fictional regional // measurement network; storage class, signed-link mechanics, and provider name are not present // in this lesson. // Columns: bucket, object group, bucket-level permission, object-level permission ('-' = inherits // from the bucket), data type, record count, legitimate public accesses a day, identity reference // carried by the file in the group. export const GROUP = ` portal-asset-store site-assets public-read public-read asset 1400 8600 - portal-asset-store upload-preview public-read - subscriber-personal 9260 0 - measurement-store daily-upload private public-read measurement 41800 40 - measurement-store raw-read private - measurement 512000 0 - billing-output-store subscriber-invoice private signed billing 24500 120 - billing-output-store bulk-export private public-list billing 310 0 - raw-archive historical-read private - measurement 1240000 0 - raw-archive shared-pull private public-read measurement 8700 6 - backup-store nightly-backup private - backup 620 0 - backup-store old-backup private public-read backup 180 0 - template-store distribution-template private public-read configuration 47 9 role-distribution report-store monthly-report private signed billing 36 30 - log-store application-log private - log 88000 0 -` .trim().split('\n').map((s) => { const p = s.trim().split(/\s+/); return { bucket: p[0], group: p[1], bucketPerm: p[2], objectPerm: p[3], type: p[4], records: Number(p[5]), legitimate: Number(p[6]), identity: p[7] === '-' ? null : p[7] }; }); export const PUBLIC = ['public-read', 'public-list']; // Effective permission: if the object level writes a value, that governs; if not, the bucket // level is inherited. export const effective = (g, d = {}) => { const bucket = d.bucketClosed ? 'private' : g.bucketPerm; const object = d.closed && d.closed(g) ? 'private' : g.objectPerm; return object === '-' ? bucket : object; }; export const open = (g, d) => PUBLIC.includes(effective(g, d)); // Access graph (same as the previous lessons): the walk from an identity reference found in an // open file. const ACCESS = { 'account-collector': 'measurement-store measurement-queue', 'account-validator': 'measurement-store measurement-queue raw-archive', 'account-portal': 'subscriber-database portal-asset-store work-order-queue', 'account-report': 'report-store billing-output-store log-store', 'role-billing': 'billing-database billing-output-store mail-dispatch subscriber-database', 'role-archive': 'raw-archive backup-store', 'role-backup': 'backup-store billing-database subscriber-database measurement-store', 'role-distribution': 'distribution-configuration template-store instance-management', 'role-log': 'log-store audit-log', 'role-management': 'instance-management key-management secret-store', }; const ASSUME = [['account-collector', 'role-archive'], ['account-validator', 'role-archive'], ['role-archive', 'role-backup'], ['role-backup', 'role-management'], ['account-portal', 'role-billing'], ['role-billing', 'role-log'], ['account-report', 'role-log'], ['role-distribution', 'role-management']]; export function walk(start) { const g = new Set([start]), item = new Set(); let row = [start]; while (row.length) { const s = []; for (const k of row) { for (const r of ACCESS[k].split(' ')) item.add(r); for (const [a, b] of ASSUME) if (a === k && !g.has(b)) { g.add(b); s.push(b); } } row = s; } return item; } // Exposure radius: buckets read without an identity + the walk of the identity reference in an // open file. export function radius(d) { const y = new Set(GROUP.filter((g) => open(g, d)).map((g) => g.bucket)); for (const g of GROUP.filter((x) => open(x, d) && x.identity)) for (const r of walk(g.identity)) y.add(r); return y; }
// cloud/run1.mjs — how many items are publicly open, which data type, how many records import { GROUP, open, effective, radius } from './storage.mjs'; const print = (g, ...s) => console.log(s.map((v, i) => g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join('')); const A = [-22, -23, -22, -21, 9, 7]; print(A, 'bucket', 'object group', 'effective permission', 'data type', 'records', 'legit'); for (const g of GROUP.filter((x) => open(x))) print(A, g.bucket, g.group, effective(g), g.type, g.records, g.legitimate); const records = (f) => GROUP.filter(f).reduce((a, g) => a + g.records, 0); console.log(`\nopen item ${GROUP.filter((g) => open(g)).length}/${GROUP.length}, ` + `open records ${records((g) => open(g))} / total ${records(() => true)} ` + `(${(records((g) => open(g)) / records(() => true) * 100).toFixed(1)}%)`); const type = [...new Set(GROUP.map((g) => g.type))]; console.log(`data type ${type.filter((t) => GROUP.some((g) => g.type === t && open(g))).length}/` + `${type.length} open; type left closed: ` + type.filter((t) => GROUP.every((g) => g.type !== t || !open(g))).join(', ')); const y = radius({}); console.log(`\nbucket read without identity ${new Set(GROUP.filter((g) => open(g)).map((g) => g.bucket)).size}` + `; radius with the identity reference in an open file ${y.size}`);
bucket object group effective permission data type records legit portal-asset-store site-assets public-read asset 1400 8600 portal-asset-store upload-preview public-read subscriber-personal 9260 0 measurement-store daily-upload public-read measurement 41800 40 billing-output-store bulk-export public-list billing 310 0 raw-archive shared-pull public-read measurement 8700 6 backup-store old-backup public-read backup 180 0 template-store distribution-template public-read configuration 47 9 open item 7/13, open records 61697 / total 1926853 (3.2%) data type 6/7 open; type left closed: log bucket read without identity 6; radius with the identity reference in an open file 10
Seven of thirteen object groups are publicly open. The open record count is sixty-one thousand six hundred and ninety-seven, only three point two percent of the total — the volume measure looks small. The data type measure does not say the same thing: six of seven types are open, and the only type left closed is the application log.
Four rows need to be read separately. site-assets is open on purpose and gets eight thousand six
hundred legitimate accesses a day. upload-preview sits in the same bucket, inherits the
bucket-level permission, and carries nine thousand two hundred and sixty subscriber personal
records; it gets zero legitimate public accesses a day. The only difference between the two
groups is that the second one never wrote its own permission.
daily-upload and shared-pull are the opposite case: their buckets are private, openness is
written at the object level individually, and both have a legitimate justification. old-backup
and bulk-export are also open at the object level but get no legitimate access at all — meaning a
justification once existed, and when the justification lapsed, the permission did not.
The last row gives the lesson’s second measure. A request with no identity reaches six buckets. The
distribution template inside template-store carries an identity reference, and the walk that
starts from that reference brings the radius up to ten. Four items — distribution configuration,
instance management, key management, and secret store — were never opened by any storage
permission; they come from a forty-seven-record configuration file.
Five Fixes
// cloud/run2.mjs — five fixes: open items, open records, radius, and stopped legitimate access import { GROUP, open, radius } from './storage.mjs'; const FIX = [ ['no audit', {}], ['public access closes at the bucket level', { bucketClosed: true }], ['items with 0 legitimate public access close', { closed: (g) => g.legitimate === 0 }], ['the open item of type configuration closes', { closed: (g) => g.type === 'configuration' }], ['all public access closes', { bucketClosed: true, closed: () => true }], ]; const print = (g, ...s) => console.log(s.map((v, i) => g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join('')); const A = [-43, 7, 9, 9, 11]; print(A, 'fix', 'open', 'open', 'radius', 'stopped job'); print(A, '', 'item', 'record', '', 'a day'); for (const [name, d] of FIX) { const a = GROUP.filter((g) => open(g, d)); print(A, name, a.length, a.reduce((x, g) => x + g.records, 0), radius(d).size, GROUP.filter((g) => open(g) && !open(g, d)).reduce((x, g) => x + g.legitimate, 0)); } console.log('\nthe two components of the radius (buckets read without identity / items added from the reference):'); for (const [name, d] of FIX) { const bucket = new Set(GROUP.filter((g) => open(g, d)).map((g) => g.bucket)).size; console.log(` ${name.padEnd(43)}${String(bucket).padStart(2)} / ${radius(d).size - bucket}`); } const dropped = [...radius({})].filter((r) => !radius(FIX[3][1]).has(r)).sort(); console.log(`\nitems dropped from the radius when the configuration-type item closes ${dropped.length}:`); console.log(' ' + dropped.join(', '));
fix open open radiusstopped job
item record a day
no audit 7 61697 10 0
public access closes at the bucket level 6 52437 10 0
items with 0 legitimate public access close 4 51947 8 0
the open item of type configuration closes 6 61650 5 9
all public access closes 0 0 0 8655
the two components of the radius (buckets read without identity / items added from the reference):
no audit 6 / 4
public access closes at the bucket level 6 / 4
items with 0 legitimate public access close 4 / 4
the open item of type configuration closes 5 / 0
all public access closes 0 / 0
items dropped from the radius when the configuration-type item closes 5:
distribution-configuration, instance-management, key-management, secret-store, template-store
The second row is this lesson’s decision-carrying finding. Closing public access at the bucket level closes nine thousand two hundred and sixty personal records — fifteen percent of the open records — and stops not a single legitimate job a day. Against that, the spread radius stays at ten. The only item closed is the group that inherits the bucket-level permission; the six permissions explicitly written at the object level stay in place, and the group carrying the identity reference is one of them.
The fourth row shows the reverse. Closing the single open item of type configuration closes forty-seven records — less than a thousandth of the open records — and brings the radius down from ten to five. The cost is nine legitimate accesses a day. The bucket-level fix closes one hundred and ninety-seven times as many records as this fix, and lowers the radius by zero items.
The block below separates out why. The radius has two components: the number of buckets read without an identity, and the number of items added from the identity reference. The first three fixes never touch the second component — the four items stay in place across all three. The fourth fix zeroes out the second component and lowers the first component by only one. The fix that lowers record volume and the fix that lowers the radius are not the same fix.
Each Open Group’s Individual Share
Seven open groups give a combined figure; what each one carries is measured separately.
// cloud/run3.mjs — open groups one by one: how large a radius each carries on its own import { GROUP, open, radius, walk } from './storage.mjs'; const alone = (g) => { // radius if only this group were open const y = new Set([g.bucket]); if (g.identity) for (const r of walk(g.identity)) y.add(r); return y.size; }; const print = (g, ...s) => console.log(s.map((v, i) => g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join('')); const A = [-21, -15, 9, 10, 8, 15]; print(A, 'open group alone', 'data type', 'records', 'radius', 'legit', 'records / item'); const openGroup = GROUP.filter((g) => open(g)); for (const g of openGroup) print(A, g.group, g.type, g.records, alone(g), g.legitimate, Math.round(g.records / alone(g))); const total = openGroup.reduce((a, g) => a + alone(g), 0); console.log(`\nsum of the individual radii ${total}, radius measured together ` + `${radius({}).size}: ${total - radius({}).size} item comes from two groups at once`);
open group alone data type records radius legit records / item site-assets asset 1400 1 8600 1400 upload-preview subscriber-personal 9260 1 0 9260 daily-upload measurement 41800 1 40 41800 bulk-export billing 310 1 0 310 shared-pull measurement 8700 1 6 8700 old-backup backup 180 1 0 180 distribution-templateconfiguration 47 5 9 9 sum of the individual radii 11, radius measured together 10: 1 item comes from two groups at once
Six groups have a radius of one: the open bucket is read and that is where it ends. The seventh is
five. The last column turns this into a single ratio — records per item. daily-upload carries
forty-one thousand eight hundred records per item; distribution-template carries nine. The
four-thousand-six-hundred-fold difference explains why the two groups get different treatment on
the same list: one is data to be read, the other is a door to be opened.
The last row tests whether the measure is additive. The sum of the individual radii is eleven, the radius measured together is ten. The difference is one item: because two open groups sit in the same bucket, that bucket was counted twice. The spread radius is a set size, and it cannot be summed group by group.
The Separation of the Two Measures
Exposure cannot be given as a single number, because there are two separate forms of harm. Reading personal records is a direct harm, and its size is measured by record count: nine thousand two hundred and sixty subscriber records are read without requiring any walk at all. Reading the configuration file, on the other hand, is small on its own — forty-seven records — but it is a door that opens onto four other items.
The two measures produce different priorities. Sorted by record volume, the first fix is
upload-preview; sorted by radius, the first fix is distribution-template. The right order is
that both are close to free: the first stops zero legitimate accesses a day, the second stops nine.
The fix in the third row — closing items with no legitimate access at all — comes down to four open
items and costs zero, but it does not cover the configuration item, because that item gets nine
legitimate accesses a day.
The last row draws the boundary. Closing all public access zeroes out the radius; the cost is eight thousand six hundred and fifty-five accesses a day, and eight thousand six hundred of those belong to the subscriber portal’s assets. This is not a storage decision, it is a decision about how the portal works — and that item being open contributes nothing to the radius, because it carries no identity reference.
Summary
- 7 of 13 object groups are publicly open; the open record count is 61,697, 3.2% of the total. The data type measure is different: 6 of 7 types are open, and the only type left closed is the application log.
- The effective permission comes from the object level: because one of two groups sitting in the same bucket never wrote its own permission, 9,260 subscriber personal records stay open while getting 0 legitimate accesses a day.
- The exposure radius has two components: 6 buckets read without an identity and 4 items added from the identity reference in an open configuration file — a total of 10.
- Closing public access at the bucket level closes 9,260 records, stops 0 jobs a day, and leaves the radius at 10. The fix that lowers record volume may not lower the radius.
- Closing the 47-record configuration item brings the radius down from 10 to 5 and stops 9 legitimate accesses a day; it closes 197 times fewer records than the bucket-level fix, and lowers the radius by 5 items.
- Closing all public access zeroes out the radius; the cost is 8,655 accesses a day, and 8,600 of those come from a single item that contributes nothing to the radius.
Next Step
This lesson took the content of an open file to be readable. The records were encrypted on disk, but that was no obstacle for a subject that could read them — because decryption happened on the provider’s side, as part of the read request. The question not asked is this: does the radius change depending on who holds the decryption key? The next lesson runs key ownership through three modes and measures a single thing: the number of items the leaked identity can read, how many when the key is with the provider and how many when it is with the customer.
To keep your progress and take notes, Log in
My notes
Log in to take notes.