Organization-grade cloud and supply-chain attack-surface discovery: S3/GCS/Azure Blob bucket discovery via observed-name mining (CNAME/cert-SAN/Wayback) and bounded two-class permutation (6 prefixes x 15 suffixes on trusted tokens, bounded target-bound expansion on subdomain stems), existence (HEAD/GET) vs public-listing confirmation, object-key triage into 9 value tiers (database dumps, credentials, IaC state, kubeconfig, VCS dirs, config, archives, PII, logs), dangling-CNAME bucket-takeover detection, and the ownership-gated severity model that stops an unattributable public bucket from becoming a false CRITICAL; the fully offline AWS-account-ID recovery from a leaked AKIA/ASIA/AROA access key (base32 decode, runnable stdlib Python, canonical test vector, AWS-documentation-example-ID screening); dependency-confusion confirmation for npm/PyPI (internal-signal classifier -- private-registry binding vs org-namespace match -- paired with a read-only public-registry 404 check and the npm scope-claimability nuanc
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Organization-grade cloud and supply-chain attack-surface discovery: S3/GCS/Azure Blob bucket discovery via observed-name mining (CNAME/cert-SAN/Wayback) and bounded two-class permutation (6 prefixes x 15 suffixes on trusted tokens, bounded target-bound expansion on subdomain stems), existence (HEAD/GET) vs public-listing confirmation, object-key triage into 9 value tiers (database dumps, credentials, IaC state, kubeconfig, VCS dirs, config, archives, PII, logs), dangling-CNAME bucket-takeover detection, and the ownership-gated severity model that stops an unattributable public bucket from becoming a false CRITICAL; the fully offline AWS-account-ID recovery from a leaked AKIA/ASIA/AROA access key (base32 decode, runnable stdlib Python, canonical test vector, AWS-documentation-example-ID screening); dependency-confusion confirmation for npm/PyPI (internal-signal classifier -- private-registry binding vs org-namespace match -- paired with a read-only public-registry 404 check and the npm scope-claimability nuance the public search API misses); and passive cloud-native/container/Kubernetes/CI control-plane fingerprinting (Lambda URLs, API Gateway, Cloud Run, App Service, kubelet/etcd/K8s API/dashboard, Jenkins/GitLab/Argo CD) as an org-attribution and exposure surface. Passive/discovery only -- no exploitation, no credential submission, no active control-plane confirmation (a stage-6 validate_cloud active tier is described but out of scope). Use when enumerating a target's cloud storage footprint, recovering an AWS account ID from a leaked key, confirming a supply-chain dependency-confusion vector, or fingerprinting cloud-native/K8s/CI infrastructure for an authorized external recon engagement.
Companion skills: osint-methodology (the pipeline this plugs into — Stage 2 asset
expansion, Stage 4 exposure analysis, Stage 5 supply-chain confirmation). offensive-osint
§16.8 (bucket-permutation raw wordlist), §16.17 (cloud-native URL pattern table), §16.18–
§16.19 (container/K8s/CI paths + active curl recipes), §44 (package-registry search). This
skill does not repeat those lists — it builds the reasoning layer on top: the
ownership-gated bucket severity model, the offline AKIA→account-ID decode, the
dependency-confusion two-part confirmation contract, and cloud-native/K8s as an
org-attribution surface, not just another probe list.
0. When to Use / When NOT
Use this skill when: you're mapping an authorized target's cloud and supply-chain
footprint — enumerating storage buckets and judging whether a hit is actually the target's
risk (not a stranger's public bucket that happens to match a permutation); recovering the
AWS account ID behind a leaked access key you already hold (dead or live); confirming
whether an internal-looking npm/PyPI dependency is a registerable supply-chain vector; or
fingerprinting cloud-native (Lambda/Cloud Run/App Service/…) and container/K8s/CI
control-plane surface for org attribution and exposure triage.
Do NOT use this skill when: you just need the raw bucket-permutation wordlist,
cloud-native URL pattern table, or container/K8s/CI path list with no reasoning layer — go
straight to offensive-osint §16.8/§16.17–16.19/§44. Do NOT use it for anything past
discovery/confirmation: registering an unclaimed package, submitting AWS credentials,
authenticating to a Kubernetes API, or confirming a fingerprinted control plane actually
answers unauthenticated (that's a stage-6 --validate --validate-cloud active tier —
described, never performed, §5/§9.4).
1. Authorization & Legal Posture
Reuses osint-methodology §1 — assets you own or have written authorization to assess.
Three of this skill's four subsystems carry a distinct authorization shape, worth being
explicit about before you run any of them:
Bucket probing (§6) is a real HTTP GET against bucket infrastructure that may or may
not be the target's — the same "active but low-intrusion" tier as any other GET against
target-adjacent infra. Standard engagement authorization applies.
AWS account-ID decode (§7) is fully offline. No authorization question beyond already
lawfully holding the leaked key.
Dependency-confusion confirmation (§8) issues live GETs, but only against the public
npm/PyPI registries — . This is why it's explicitly in scope
even though it's "active."
zero packets to the target
Cloud-native/K8s/CI fingerprinting (§9) is pattern-matching over hostnames and ports
already resolved by earlier recon — no new network traffic of its own.
Org-namespace-matched dependency name (medium strength) with no private-registry binding — namesake-prone.
FIRM
Bucket exists (403/private) and is name-tied or CNAME/SAN/Wayback-observed; AWS account ID offline-decoded from a leaked key with no live validation; cloud-native endpoint pattern-matched via an owned CNAME/cert-SAN/subdomain FQDN; a container/K8s orchestration port is open (directly observed — its auth posture is not).
CONFIRMED
Bucket is publicly listable/readable and ownership-verified; AWS account ID corroborated by a live STS-validated key; dependency-confusion "strong" signal (private-registry binding) confirmed unclaimed via the public-registry 404 + scope-claimability check.
An unattributable public bucket hit does not get a confidence label at all — it never
reaches the finding stream (§6.3).
3. Output Format
Finding:
id: <stable hash or UUID>
module: <technique that discovered it>
asset_key: <typed key, e.g. bucket:s3:acme-backup, account:aws:609629065308>
category: <PUBLIC_BUCKET | INFO_DISCLOSURE | TAKEOVER | DEPENDENCY_CONFUSION | MISCONFIG | OPEN_SERVICE | EXPOSED_PANEL>
severity: <info|low|medium|high|critical>
confidence: <tentative|firm|confirmed>
title: <one-line summary>
description: <2-5 sentences — issue + attacker impact, not a terse restatement of the title>
evidence:
url: <where found>
timestamp: <UTC ISO8601>
sha256: <hash of any downloaded artifact — never an object body, see §5>
raw: <truncated to 2 KiB>
references: [<advisory URL, vendor doc>]
remediation: <action the asset owner can take>
UTC timestamps everywhere.
4. Source Hygiene & Citations
URL + UTC timestamp + SHA-256 + tool version + run_id, every artifact. For bucket listings,
hash the listing response (the XML/JSON), never an object's contents — this skill never
fetches an object body (§5). For a decoded AWS account ID, cite the key string it was
derived from (redacted to first/last 4 chars in client-facing output) and the algorithm
version. For a dependency-confusion hit, cite both registry-check timestamps (the package
404 and, for scoped npm, the scope-ownership 404) — claimability is a point-in-time fact
that can flip the moment someone else registers the name.
5. Do NOT
Do NOT fetch or download the contents of any object inside a bucket. Listing keys /
sampling object names from the listing response is in scope (§6.3); retrieving an object's
body is not — the reference implementation gates raw object reads behind --validate.
Do NOT claim CRITICAL severity for a bucket, endpoint, or account hit that isn't
ownership-verified. An unattributable public hit is not the client's risk — see the
ownership-gated model, §6.3.
Do NOT register, reserve, or publish a package name found unclaimed by the
dependency-confusion check. Confirmation only — §8.7.
Do NOT use a decoded/derived AWS account ID to call AWS APIs (sts:AssumeRole,
GetCallerIdentity) against real infrastructure, with your own or the target's
credentials. That reverse step needs the operator's own AWS credentials against a third
party's account, is CloudTrail-logged on the target's side, and is out of this skill's
scope entirely — describe the pivot value (§7.1), never perform it.
Do NOT actively probe or authenticate against a fingerprinted Kubernetes API, etcd,
kubelet, or Docker daemon endpoint to confirm its auth posture. Passive fingerprint only —
live confirmation is a stage-6 --validate --validate-cloud active tier, out of scope.
Do NOT single-source attribute a bucket/account/dependency/endpoint to the target — apply
the rule of three (osint-methodology §2) or the explicit ownership signals in §6.3/§7.7/§9.1.
6. Storage Bucket Discovery — Candidate Generation, Existence vs. Listing, and the Ownership-Gated Severity Model
6.1 Two-class candidate generation
Two distinct expansion classes — mixing them either misses brand buckets or produces a
candidate storm (an unconstrained full-expansion-per-subdomain-stem approach measured at
~95k candidates on a large-subdomain target).
Class A — trusted tokens (apex root, the domain with dots replaced by hyphens, the
domain with dots stripped, and a sanitized --company token if supplied): full
prefix × suffix expansion.
Class B — subdomain stems (the first label of every discovered subdomain, excluding
www/mail/ns1/ns2): bounded and target-bound only —
Bare probe, but only when the stem is distinctive: not in the broad (~90-entry)
generic-stem filter (api, admin, backup, dev, staging, mail, data, docs,
internal, vault, secure, sandbox, preprod, … — a stricter, larger list than the
47-word variant in offensive-osint §16.8) and longer than 3 characters — or the stem
already contains a trusted token.
Target-bound permutation only: {apex_root|company}-{stem} and
{stem}-{apex_root|company} (hyphen joiner, both orders).
No standalone prefix/suffix expansion is ever applied to a subdomain stem. Every candidate
must satisfy the bucket-name shape ^[a-z0-9][a-z0-9.\-]{1,61}[a-z0-9]$ (3–63 characters).
Observed-name mining bypasses the filter entirely. A bucket name mined from the target's
own DNS, certificate, or archived pages is near-certain to be theirs, so it's probed
regardless of the rules above:
subdomain CNAME chains resolving to *.s3(.<region>).amazonaws.com,
*.storage.googleapis.com, or *.blob.core.windows.net
certificate SANs matching the same three host patterns
archived (Wayback) URLs, path- or vhost-style, referencing an S3/GCS/Azure Blob host
6.2 Probe technique — existence vs. listing
One GET per candidate per provider — no separate HEAD, since the GET body is needed anyway
to classify listing:
Provider
URL
Listing marker (200)
Exists, no listing
Not found
S3
GET https://{name}.s3.amazonaws.com/
<ListBucketResult → LISTABLE
200/301/307 (no marker) or 403 → exists (403 = private; parse <Code> for the deny reason)
NoSuchBucket (404) → absent
GCS
GET https://storage.googleapis.com/{name}
<ListBucketResult → LISTABLE
403 → exists, private
404 → absent
Azure Blob
GET https://{name}.blob.core.windows.net/?comp=list
<EnumerationResults → LISTABLE
400/403 → account exists, private
404 → absent
S3 region redirects (301/307) are followed once before final classification. On a listable
hit, sample up to 200 object keys straight out of the listing body already returned
(<Key>…</Key> for S3/GCS, <Blob><Name>…</Name> for Azure) — never a second request per
object, never a fetch of an object's contents.
6.3 The ownership-gated severity model
This is the discipline that stops a permutation hit on a stranger's globally-registered
bucket from becoming a false CRITICAL — the exact class of false positive an earlier,
unconstrained version of this logic produced: unverified public/listable hits emitted at
MEDIUM/LOW severity were the documented source of a 171-finding false-positive flood before
this gate was added. (A related, separate incident — filing a merely-exists-but-private
403/400 bucket as a PUBLIC_BUCKET exposure — produced a "33 listable buckets" false read on
a fully-private estate; §6.2's reachability classification is what fixed that one.) Two
independent axes decide the outcome — what's reachable and whether ownership is
verified — and the outcome is no finding at all, not a downgraded severity, whenever
the second axis fails.
Ownership verified means at least one of:
name-tied — the candidate name contains a trusted token (apex root / domain variant /
--company)
observed — the name was mined from the target's own CNAME, cert SAN, or archived URL
(§6.1)
branded object key — on a listable bucket, at least one returned object key itself
names the target (checked after listing, so this can promote an otherwise-generic name)
Reachability
Ownership unverified
Ownership verified
Listable (public listing)
No finding. Recorded as a Bucket asset only.
PUBLIC_BUCKET, CRITICAL, CONFIRMED
Public-read, not listable
No finding. Asset only.
PUBLIC_BUCKET, MEDIUM, CONFIRMED
Exists, access denied (403/400)
No finding. Asset only.
INFO_DISCLOSURE, INFO, FIRM
Not found (404)
Nothing — unless a live subdomain CNAME still points at the absent name (§6.4)
—
The unverified rows are a deliberate zero, not a soft downgrade to LOW/INFO. A generic
permutation like static-delivery or logs-archive matches thousands of unrelated
companies' real, live buckets globally; surfacing every one as the client's finding is
exactly what this model exists to prevent. The candidate is still recorded as a graph asset
for correlation and re-probe on a future scan — it simply never enters the finding stream
unattributed.
Object-key triage on a listable bucket — classify the exposed keys into value tiers so
the write-up states what is exposed, not merely that listing is open. Pure filename-pattern
match over the listing already returned — no object body is ever fetched:
Lead the finding title with the highest tier present (e.g. "publicly listable — credentials
exposed") — that one line is what turns a generic bucket finding into something the client
acts on immediately.
6.4 Dangling-CNAME bucket takeover — a bonus lead, always in scope
A candidate that returns 404/NoSuchBucket but is still the live target of a subdomain's
CNAME is a claimable takeover, independent of the ownership-gating table above — the
live CNAME is the ownership evidence (a stranger's dangling bucket doesn't happen to have
the target's subdomain pointed at it). Detection only: never register the bucket to prove
it. Category TAKEOVER, HIGH, CONFIRMED — the 404-plus-dangling-CNAME combination is
unambiguous.
6.5 Recipes
Probe (bash):
NAME="acme-backup"
for url in \
"https://${NAME}.s3.amazonaws.com/" \
"https://storage.googleapis.com/${NAME}" \
"https://${NAME}.blob.core.windows.net/?comp=list"; do
echo "== $url =="
curl -sk -m 8 -o /tmp/body.xml -w 'status=%{http_code}\n' "$url"
grep -oE '<(ListBucketResult|EnumerationResults)|<Key>[^<]+</Key>|<Code>[A-Za-z]+</Code>' /tmp/body.xml | head -20
done
Candidate generation — apex + full prefix/suffix expansion (bash):
APEX="acme"
PREFIXES=("" "backup-" "assets-" "static-" "dev-" "prod-")
SUFFIXES=("" "-backup" "-assets" "-static" "-media" "-data" "-uploads" "-dev" "-prod" "-staging" "-logs" "-private" "-public" "-dump" "-archive")
for p in "${PREFIXES[@]}"; do
for s in "${SUFFIXES[@]}"; do
echo "${p}${APEX}${s}"
done
done
7. AWS Account-ID Recovery from a Leaked Access Key (Offline Decode)
Every AWS access key ID has the 12-digit owning account number encoded inside the key
string itself — recoverable by a deterministic base32 decode with no secret key, no
network call, and no live AWS credential. A single leaked AKIA…/ASIA…/AROA… string
already in hand — even one that's long dead or rotated — discloses the org's AWS account
number. This is genuinely offline OSINT (no authorization concern beyond already lawfully
holding the key, §1), and it's a real gap in the general arsenal: offensive-osint §22.7
covers ARN-regex extraction and accountId JSON-field scraping, but not this decode.
7.1 Why the account ID matters
Cross-account trust-policy abuse — target trust policies that reference the account by
number.
IAM role/principal enumeration — an sts:AssumeRole error-message differential ("not
authorized" vs. "does not exist") distinguishes real principals from guesses. This reverse
step needs the operator's own AWS credentials against the target account and is
intrusive/CloudTrail-logged on the target's side — out of scope for this skill,
described here only.
Targeted IAM-role phishing — a real account number lends legitimacy to a
role-assumption pretext.
7.2 Key prefixes → entity type
The decode is identical across every prefix; the prefix only tells you which IAM entity type
the key belongs to.
Uppercase the string; take the first 20 characters.
Confirm the first 4 characters are a valid prefix (§7.2) and the remaining 16 match
[A-Z2-7]{16}.
Base32-decode those 16 characters → 10 raw bytes.
Take the first 6 bytes, interpret as a big-endian integer z.
account = (z & 0x7FFFFFFFFF80) >> 7
Zero-pad to 12 digits. If the result exceeds 999,999,999,999, the input wasn't really a
key (reject).
Published by Aidan Steele / Tenable; reference implementation credit to A. Frichetten
(hackingthe.cloud).
7.4 Runnable — stdlib-only Python
#!/usr/bin/env python3
"""Offline AWS account-ID decode. No secret, no network, no live key needed.
Mirrors core/aws_account.py's account_id_from_access_key(). Stdlib only.
Usage:
echo "ASIAY34FZKBOKMUTVV7A" | python3 aws_account_decode.py
python3 aws_account_decode.py AKIA... ASIA...
python3 aws_account_decode.py < file_with_keys.txt
"""
import base64
import re
import sys
_VALID_PREFIXES = {"AKIA", "ASIA", "AROA", "AIDA", "AGPA", "AIPA",
"ANPA", "ANVA", "ABIA", "ACCA"}
_KEY_RE = re.compile(r"\b(A[A-Z0-9]{3}[A-Z2-7]{16})\b")
_MASK = 0x7FFFFFFFFF80
_MAX_ACCOUNT = 999_999_999_999
# AWS documentation / reserved example account IDs — never report these as a
# real disclosure; they pervade IAM policy docs, Terraform tutorials, SDK samples.
EXAMPLE_ACCOUNT_IDS = {
"123456789012", "111122223333", "222233334444", "333344445555",
"444455556666", "555566667777", "666677778888", "777788889999",
"888899990000", "999900001111", "012345678901", "000000000000",
"123412341234", "101010101010",
}
def account_id_from_access_key(key: str):
k = (key or "").strip().upper()[:20]
if len(k) != 20 or k[:4] not in _VALID_PREFIXES:
return None
body = k[4:]
if not re.fullmatch(r"[A-Z2-7]{16}", body):
return None
try:
decoded = base64.b32decode(body)
except Exception:
return None
z = int.from_bytes(decoded[:6], "big")
account = (z & _MASK) >> 7
if account > _MAX_ACCOUNT:
return None
return f"{account:012d}"
def main() -> int:
text = " ".join(sys.argv[1:]) or sys.stdin.read()
seen = set()
for m in _KEY_RE.finditer(text):
key = m.group(1)
if key in seen:
continue
seen.add(key)
acct = account_id_from_access_key(key)
if acct is None:
continue
flag = " (AWS EXAMPLE ID -- not a real disclosure)" if acct in EXAMPLE_ACCOUNT_IDS else ""
print(f"{key} -> {acct}{flag}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
7.5 Test vector
ASIAY34FZKBOKMUTVV7A -> 609629065308
Verify any reimplementation against this before trusting its output.
7.6 Example-account screening
Screen every decoded (and every ARN-extracted) account ID against the 14 documented AWS
example account IDs above — 123456789012 chief among them, present in nearly every AWS IAM
policy doc, Terraform tutorial, and SDK sample. Skip this check and a plain documentation
page in the response corpus mints a false "owned AWS account" finding.
Two complementary offline extraction paths, in addition to the decode:
AWS-qualified accountId field only — deliberately requires the aws qualifier
(aws[_-]?account[_-]?id), never a bare "accountId":"…", since a bare field is just as
likely an internal billing/customer record that happens to be 12 digits.
7.7 Ownership + severity/confidence
Decode every access key found in the corpus, plus pull account IDs from ARNs and
AWS-qualified accountId fields. Ownership is structural, not scored:
Disclosure path
Ownership
Severity
Confidence
Key/ARN found on an in-scope host (the subdomain equals the target, or is a subdomain of it)
Owned
MEDIUM
FIRM
Key/ARN found on an out-of-scope/third-party host that merely references the account
Referenced
INFO
FIRM
Either of the above, additionally corroborated by a live STS-validated key (read-only GetCallerIdentity, offensive-osint §23.2)
Owned
MEDIUM
CONFIRMED
Every leaking page emits a DISCLOSES_ACCOUNT edge to the account it references — for owned and
referenced accounts alike. An owned account additionally earns an OWNED_BY-target edge — a
legitimate attack-path pivot. A referenced-only account has just the DISCLOSES_ACCOUNT edge and
is never treated as the org's own escalation target — it's a third party's account, though still
worth recording.
Note the deliberate severity ceiling: MEDIUM even for an owned account, because the finding
itself is "your account number is now public" — real but modest on its own (rotate the leaked
key; the number can't be un-disclosed). Severity escalates only through what an attacker
does with the number next (§7.1), which this skill does not perform.
8. Dependency Confusion — Internal Signal + Public-Registry Unclaimed Confirmation
offensive-osint §44 covers registry search and secret hunting inside packages the target
published. This is the opposite direction: what does the target's own manifests
reference? A dependency name that carries an internal signal and is unclaimed on the
public registry is a name an attacker can register today and have pulled into the target's
build the next time a resolver falls back to the public index.
8.1 Manifest mining (read the target's own captured corpus — never re-fetch for this)
package-lock.json (v1 nested dependencies; v2/v3 packages keyed by node_modules/…)
npm package names — richest source, includes transitive/scoped deps
.npmrc
{scope}:registry= bindings + a bare default registry= — the private-registry signal
requirements.txt
PyPI names + --index-url/--extra-index-url; screens out -e/local-path/git+/direct-URL deps (never a confusion vector)
pyproject.toml
PEP 621 project.dependencies + Poetry tool.poetry.dependencies/group.*.dependencies, plus [[tool.poetry.source]] private-index URLs; a Poetry dep carrying an explicit git/url/path/file key is direct-source and screened out
pip.conf / pip.ini
index-url/extra-index-url → private pip index signal
Any .js/.mjs/.cjs/.map body
scoped npm specifiers (@scope/name) referenced in bundled/sourcemap code
8.2 The internal-signal classifier (zero I/O — pure text logic)
A dependency name is a candidate only when it carries one of these signals:
Ecosystem
Signal
Strength
npm, scoped (@scope/pkg)
the scope is bound to a private registry in .npmrc
strong
npm, scoped or unscoped
.npmrc sets a private default registry
strong
npm, scoped or unscoped
package name/scope matches an org-namespace token (≥4 chars, not a generic corporate word — corp, solutions, technologies, holdings, …)
medium
PyPI
a private index (--index-url / Poetry source / pip.conf) is configured anywhere in the corpus
strong
PyPI
package name matches an org-namespace token
medium
Org-namespace tokens come from the target's registrable brand label — never the leftmost
DNS label; a target reached via staging.acme.com yields the token acme, never staging
— plus any ORG-typed asset already in the graph. A bare unscoped name with no
private-registry binding and no namespace match carries no signal — indistinguishable from a
typo or a yanked release — and is dropped, never emitted as a candidate.
8.3 The two-part confirmation contract — neither half alone is a finding
An internal signal alone is not proof the name is available — plenty of internal packages
are also correctly published privately-but-globally-unique. A public-registry 404 alone is
meaningless — a typo, an unpublished/yanked version, or a local-only dep all 404 too.
Both must hold:
Internal signal (§8.2) — screens out ordinary public dependencies and unknown bare names.
Public-registry existence check returns 404 — a read-only GET against the real
registry.npmjs.org / pypi.org, never the target. This is exactly why it's in scope
under the pack's "public-registry 404 checks" line even though it's a live network call:
the traffic lands entirely on a third-party public registry, zero packets to the client.
Fail closed on any ambiguity — a 200 (published), 403, 5xx, or timeout never emits a
finding. Uncertainty is not evidence of claimability.
8.4 npm scope claimability — the nuance a bare-package check misses
For a scoped name (@scope/pkg), the package 404ing isn't the interesting fact — the whole
scope must be unclaimed for the name to be squattable. Check the scope's org and user
profile pages, not the public search API:
GET https://www.npmjs.com/org/{scope} -> 200 = scope owned as an org
GET https://www.npmjs.com/~{scope} -> 200 = scope owned as a user
The name is registerable only if both 404. The npm search API is deliberately not used
for this: it returns total:0 for a scope that is privately owned but hosts no public
packages, which would falsely read as "unclaimed." The org/user profile pages return a real
404 only when the scope genuinely doesn't exist, regardless of what it might privately host.
8.5 Severity / confidence
Signal strength
Severity
Confidence
strong (private-registry binding)
HIGH
FIRM
medium (org-namespace match only)
MEDIUM
TENTATIVE — namespace matching alone is namesake-prone
Never CONFIRMED — this is static manifest analysis over a captured corpus, not a live
resolver-fallback observation. There is no "the build actually pulled the malicious package"
event to point at.
function Test-RegistryStatus {
param([string]$Url)
try {
$r = Invoke-WebRequest -Uri $Url -Method Get -TimeoutSec 15 -SkipHttpErrorCheck # PS7+; on PS 5.1 read $_.Exception.Response in the catch
return $r.StatusCode
} catch { return "error" }
}
Test-RegistryStatus "https://registry.npmjs.org/acme-internal-utils"
Test-RegistryStatus "https://www.npmjs.com/org/acme"
Test-RegistryStatus "https://www.npmjs.com/~acme"
Test-RegistryStatus "https://pypi.org/pypi/acme-internal-tools/json"
Bound the check volume — filter to internal-signalled candidates before hitting the
registry (hundreds, not thousands, per engagement) and cache scope-ownership lookups (one
org/user check per distinct scope, never per package under that scope).
8.7 Out of scope
Reserving the name — publishing a stub the org controls — is a remediation the client
performs, not a step this skill takes. This skill's ceiling is: "this name is
internal-signalled and currently unclaimed." Confirming that a live resolver fallback
actually pulls a planted package, or registering anything under the discovered name, is
active supply-chain interference and out of scope regardless of authorization — hand it to
the client as the remediation action (§8.5 evidence + §10 business translation).
9. Cloud-Native & Container/K8s/CI Control-Plane Fingerprinting — an Org-Attribution Surface
Modern infrastructure increasingly is the org-attribution signal: a Lambda Function URL, a
*.run.app Cloud Run service, or an exposed kubelet is both an exposure and a fingerprint of
which cloud account/cluster belongs to the target. Everything in this section is passive
— pattern-matching over hostnames and ports already resolved by earlier recon, zero new
network calls of this skill's own. offensive-osint §16.17–§16.19 carries the fuller
URL-pattern and active-probe tables (20 cloud-native providers, the full container-registry
search list, 11 CI/CD platforms with active curl recipes); this section is the reasoning
layer that decides what a hit is worth and whether it's the target's.
9.1 Cloud-native endpoint fingerprint
Match already-resolved hostnames — subdomain CNAME targets, certificate SANs, subdomain
FQDNs themselves, and webapp URLs — against provider URL patterns:
Provider
Pattern
Service
AWS
*.execute-api.<region>.amazonaws.com
API Gateway
AWS
*.lambda-url.<region>.on.aws
Lambda Function URL
AWS
*.<region>.elasticbeanstalk.com
Elastic Beanstalk
Azure
*.azurewebsites.net
App Service
Azure
*.azurecontainerapps.io
Container Apps
Azure
*.file|queue|table.core.windows.net
Storage (Blob object storage is handled by bucket discovery, §6 — not duplicated here)
GCP
*.run.app
Cloud Run
GCP
*.appspot.com
App Engine
GCP
*.cloudfunctions.net
Cloud Function
(offensive-osint §16.17 extends this to 20 providers — App Runner, CloudFront, ALB/ELB,
Amplify, Static Web Apps, Vercel, Netlify, Cloudflare Workers/Pages, Heroku, Render, Fly.io,
Railway, DigitalOcean App Platform.)
Ownership gating is structural, not scored — the same discipline as §6.3's bucket model,
applied to endpoints:
Provenance of the match
Ownership
A subdomain CNAME points at the pattern
Owned
A certificate SAN names the pattern
Owned
The subdomain's own FQDN matches the pattern (it is the cloud-native host)
Owned
A webapp URL matches the pattern with no independent tie to the target from the above
Unverified
Owned → MEDIUM/FIRM. Unverified (a bare pattern match with no corroborating tie) →
LOW/TENTATIVE. Confirming whether the endpoint is actually invocable without
authentication is the stage-6 --validate --validate-cloud step — out of scope; this
section only says "this cloud-native surface exists and appears to belong to the target,"
never "it's exploitable."
9.2 Container / Kubernetes control-plane exposure — passive flag, not active probe
Rather than issuing new probes, treat orchestration control-plane ports already discovered
by port/service enumeration as a likely-exposed control plane. All five carry HIGH
severity, FIRM confidence once flagged — the port being open is directly observed; the auth
posture behind it is not, which is exactly why confirmation is gated separately:
Port
Service
Why it matters if unauthenticated
2375
Docker API (plain HTTP, no TLS)
Anyone who reaches it controls the daemon — launch a host-mounting container, escape to root on the host.
2376
Docker API (TLS)
Equivalent to 2375 unless client-cert auth is strictly enforced.
2379
etcd client API
Holds the entire cluster state — every Secret, in plaintext. Read = full credential harvest; write = cluster tampering.
6443
Kubernetes API server
The cluster's control plane — a valid token, leaked kubeconfig, or anonymous-auth misconfig creates privileged pods and reads every Secret.
10250
kubelet
No auth required = pod exec on the node.
(offensive-osint §16.18 has the fuller table — kube-proxy/controller-manager/scheduler
health/metrics endpoints, cAdvisor, Helm Tiller — plus the active curl recipe for each and
the public-container-registry search list across Docker Hub/Quay/GHCR/ECR Public.)
The port being open says nothing about its auth posture. Flag it; do not connect to it to
find out. A live GET/HEAD against the endpoint to confirm whether it answers unauthenticated
is exactly the line this skill stops at.
9.3 CI/CD platform surface
Fingerprint from tech strings already collected by passive web/HTTP enumeration — Jenkins,
GitLab (self-hosted), Argo CD, Harbor, TeamCity, Drone. MEDIUM/TENTATIVE — a tech-string
match is a weaker signal than a resolved hostname pattern, hence TENTATIVE rather than FIRM.
A CI/CD console or API, when reachable unauthenticated, exposes build pipelines, stored
credentials, and artifact registries — a supply-chain foothold. offensive-osint §16.19 has
the per-platform active probe paths (/script, /api/v4/version, /gate/info, …) and the
GitHub Actions secret-leak anti-pattern catalog (workflows that echo ${{ secrets.* }} to
logs, or check out fork-PR code under pull_request_target) for an operator who chooses to
go further under their own authority.
9.4 What's out of scope here
Any GET/HEAD issued specifically to confirm a fingerprinted cloud-native endpoint or
control-plane port is reachable/unauthenticated is a stage-6
--validate --validate-cloud active tier — hard-gated behind explicit --validate plus per-target
scope confirmation, default OFF. This skill's ceiling is the passive fingerprint and the
"likely exposed, auth posture unconfirmed" flag; describe the confirmation step, never
perform it.
Customer/internal data — including live credentials — is downloadable by anyone on the internet right now.
CRITICAL
Same listable bucket, but the name can't be tied to the client
Someone's bucket is open; not provably this client's exposure. Logged for the graph, not reported as their risk.
No finding (§6.3)
AWS account ID recovered from a dead/rotated key, owned host
The org's cloud account number is now public. Not itself a breach, but every cross-account trust policy referencing that number needs review.
MEDIUM
Dependency-confusion vector, strong signal (private-registry binding), unclaimed on npm
An attacker can publish a package under this exact internal name today; the next CI run with a misconfigured registry fallback pulls it and runs arbitrary code in the build.
A managed-service endpoint is reachable; whether it's invocable without authentication is not yet proven — an open question requiring confirmation, not an assumed breach.
MEDIUM
Kubernetes API / etcd / kubelet port open, passively flagged
The cluster's control plane appears internet-reachable; authentication enforcement is unconfirmed. If it isn't enforced, this is a path to full cluster compromise.
HIGH (unconfirmed auth)
11. Skill Self-Test
Drop these into a fresh session to verify the skill loads and routes correctly.
"Generate S3/GCS/Azure bucket candidates for acme.com with subdomains api/billing/hr and probe them." → §6.1–§6.2, §6.5.
"We found a listable S3 bucket named marketplace-media from a permutation guess — can't tie it to the client. Is that CRITICAL?" → negative. §6.3 — no; an unverified public/listable hit produces no finding at all (not even INFO); it's recorded as an asset only.
"Same bucket, but its object listing includes acme-employee-payroll.csv." → §6.3 branded-object-key ownership signal — now verified, promote to CRITICAL.
"What's exposed inside a listable bucket, without downloading any object?" → §6.3 object-key triage table (filename-pattern classification of the listing body only).
"A subdomain has a live CNAME to a bucket that returns 404 NoSuchBucket. What's the finding?" → §6.4 — dangling-CNAME takeover, HIGH, CONFIRMED, independent of the ownership-gating table.
"Decode the AWS account ID from ASIAY34FZKBOKMUTVV7A." → §7.3–§7.5, test vector 609629065308.
"We found 123456789012 as an accountId in a Terraform snippet on the target's site. Real disclosure?" → §7.6 — no; that's a documented AWS example account ID, screen it out.
"Should I use the decoded account ID to enumerate IAM roles via sts:AssumeRole?" → negative. §7.1/§5 — out of scope; needs the operator's own AWS credentials against the target account and is CloudTrail-logged; describe the pivot value, don't perform it.
"package.json references @acme/design-system, and .npmrc binds @acme to a private registry. Is that a finding by itself?" → §8.2–§8.3 — internal signal only; still needs the public-registry 404 (and scope-claimability, §8.4) check before it's a finding.
"npm's public search API returns total:0 for @acme. Is the scope claimable?" → §8.4 — no; the search API is blind to privately-owned scopes. Use the org/user profile-page 404 check instead.
"Confirmed unclaimed dependency-confusion vector. Should we reserve the package name to protect the client?" → negative. §8.7 — out of scope; that's a client remediation action, not this skill's step.
"A subdomain CNAMEs to *.run.app. Is that Cloud Run endpoint owned or unverified?" → §9.1 — CNAME provenance = owned.
"Port 6443 is open per a prior port scan. What do I do next?" → §9.2/§9.4 — flag as a likely-exposed Kubernetes API control plane (passive, HIGH/FIRM); do not curl /api to confirm auth posture — that's stage-6 --validate --validate-cloud.
"Write the business-translation line for a HIGH-signal dependency-confusion finding." → §10.
12. Changelog
v1.0 (2026-08-06) — initial release. Reproduces exact logic from the
reference implementation: modules/cloud_buckets.py (two-class candidate generation,
HEAD/GET→listing probe technique, the ownership-gated severity model, 9-tier object-key
triage, dangling-CNAME takeover), core/aws_account.py + modules/aws_account_enum.py
(offline AKIA/ASIA/AROA→account-ID base32 decode, canonical test vector, AWS
documentation-example-ID screening, ownership/severity/confidence mapping),
core/dep_confusion.py + modules/dependency_confusion.py (manifest mining across 7 file
types, the internal-signal classifier, the two-part confirmation contract, the npm
scope-claimability nuance), and modules/cloud_exposure.py (cloud-native endpoint
fingerprint with structural ownership gating, passive container/K8s/CI exposure flags).
Deepens offensive-osint §16.8/§16.17–16.19/§44 rather than duplicating their wordlists.