---
title: 'Storage Exposure'
source: 'https://academia.sh/en/courses/cloud-security/storage-exposure'
course: 'Cloud and Virtualization Security'
language: en
updated: '2026-09-26T03:02:50+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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

```js
// 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;
}
```

```js
// 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

```js
// 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.

```js
// 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.
