cloud-saas-exposure
GitHub用于授权外部侦察中云存储桶发现、AWS账号ID离线恢复、供应链依赖混淆确认及云原生/K8s基础设施指纹识别的被动探测技能。
Trigger Scenarios
Install
npx skills add elementalsouls/Claude-OSINT --skill cloud-saas-exposure -g -y
SKILL.md
Frontmatter
{
"name": "cloud-saas-exposure",
"version": 1,
"triggers": [
"cloud attack surface",
"cloud exposure",
"SaaS exposure",
"cloud bucket enumeration",
"S3 bucket enum",
"GCS bucket enum",
"Azure blob enum",
"bucket takeover",
"dangling CNAME bucket",
"public cloud bucket",
"listable bucket",
"bucket ownership",
"object storage exposure",
"bucket permutation",
"AWS account ID",
"AWS account ID from access key",
"AKIA decode",
"access key account ID",
"offline AWS decode",
"base32 AWS account",
"AWS account enumeration",
"cross-account trust",
"IAM role phishing",
"sts assume role",
"dependency confusion",
"npm dependency confusion",
"PyPI dependency confusion",
"supply chain attack surface",
"unclaimed package",
"internal package registry",
"private npm registry",
"scoped npm package",
"npm scope claimability",
"package registry leak",
"cloud native fingerprint",
"Lambda function URL",
"API Gateway exposure",
"Cloud Run exposure",
"App Service exposure",
"serverless exposure",
"Kubernetes exposure",
"K8s exposure",
"kubelet exposure",
"etcd exposure",
"Docker API exposure",
"CI CD exposure",
"Jenkins exposure",
"GitLab exposure",
"control plane exposure",
"cloud footprint",
"cloud account attribution"
],
"description": "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."
}
Cloud & SaaS Exposure — Buckets, Offline AWS Account-ID Recovery, Dependency Confusion, and Cloud-Native/K8s Fingerprinting
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 — zero packets to the target. This is why it's explicitly in scope even though it's "active."
- 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.
2. Confidence Levels
Reuses osint-methodology §2. Domain-specific anchors:
| Level | Cloud/SaaS example |
|---|---|
| TENTATIVE | 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-cloudactive 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.
6 prefixes: "" (bare) backup- assets- static- dev- prod-
15 suffixes: "" (bare) -backup -assets -static -media -data -uploads
-dev -prod -staging -logs -private -public -dump -archive
→ up to 90 candidates per trusted token.
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 inoffensive-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:
| Category | Severity tier | Pattern examples |
|---|---|---|
| Database dump | critical | *.sql[.gz|.bz2|.zip], *dump*, *-backup*/*_backup*, *.bak, *.mdb, *.sqlite[3], *.dmp |
| Credentials | critical | .env*, id_rsa, id_ed25519, *.pem, *.p12, *.pfx, *.kdbx, *.ppk, *.jks, *credential* |
| IaC state | critical | *.tfstate[.backup], terraform.tfvars |
| Kubeconfig | critical | *kubeconfig*, .kube/config |
| VCS directory | high | .git/, .svn/, .hg/ |
| Config | high | config.yml/.json, settings.py, database.yml, wp-config.php, .npmrc, .dockercfg, appsettings*.json |
| Archive | high | *.zip/.tar/.tar.gz/.tgz/.7z/.rar |
| PII | high | employee, customer, passport, aadhar/aadhaar, ssn, salary, payroll, kyc (substring match) |
| Logs | medium | *.log, access[-_.]log, error[-_.]log, audit[-_.]log |
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
Probe (PowerShell):
$Name = "acme-backup"
$Targets = @(
"https://$Name.s3.amazonaws.com/",
"https://storage.googleapis.com/$Name",
"https://$Name.blob.core.windows.net/?comp=list"
)
foreach ($u in $Targets) {
try {
$r = Invoke-WebRequest -Uri $u -TimeoutSec 8 -SkipHttpErrorCheck # -SkipHttpErrorCheck is PS7+; on PS 5.1 read $_.Exception.Response in the catch
"{0,-70} status={1}" -f $u, $r.StatusCode
if ($r.Content -match '<ListBucketResult|<EnumerationResults') {
[regex]::Matches($r.Content, '<Key>([^<]+)</Key>|<Name>([^<]+)</Name>') |
Select-Object -First 15 |
ForEach-Object { $_.Groups[1].Value + $_.Groups[2].Value }
}
} catch { "$u -> error: $($_.Exception.Message)" }
}
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
Candidate generation (PowerShell):
$Apex = "acme"
$Prefixes = @("", "backup-", "assets-", "static-", "dev-", "prod-")
$Suffixes = @("", "-backup", "-assets", "-static", "-media", "-data", "-uploads", "-dev", "-prod", "-staging", "-logs", "-private", "-public", "-dump", "-archive")
foreach ($p in $Prefixes) { foreach ($s in $Suffixes) { "$p$Apex$s" } }
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:AssumeRoleerror-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.
| Prefix | Entity |
|---|---|
AKIA |
IAM user long-term access key |
ASIA |
Temporary (STS) credentials |
AROA |
IAM role |
AIDA |
IAM user |
AGPA |
IAM user group |
AIPA |
EC2 instance profile |
ANPA |
Managed policy |
ANVA |
Managed policy version |
ABIA |
AWS STS service bearer token |
ACCA |
Context-specific credential |
Shape: 4-character prefix + exactly 16 base32 characters (A–Z, 2–7) = 20 characters total.
7.3 The algorithm
- 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:
- ARN regex (any JSON/HTML/JS body):
arn:aws[a-z0-9\-]*:[a-z0-9\-]*:[a-z0-9\-]*:(\d{12}): - AWS-qualified
accountIdfield only — deliberately requires theawsqualifier (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)
| File | What it yields |
|---|---|
package.json (dependencies/devDependencies/peerDependencies/optionalDependencies) |
npm package names |
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.
8.6 Recipes
bash:
# unscoped npm
curl -sk -m 15 -o /dev/null -w '%{http_code}\n' "https://registry.npmjs.org/acme-internal-utils"
# scoped npm -- package + both scope-ownership pages
curl -sk -m 15 -o /dev/null -w '%{http_code}\n' "https://registry.npmjs.org/@acme%2Fdesign-system"
curl -sk -m 15 -o /dev/null -w '%{http_code}\n' "https://www.npmjs.com/org/acme"
curl -sk -m 15 -o /dev/null -w '%{http_code}\n' "https://www.npmjs.com/~acme"
# PyPI
curl -sk -m 15 -o /dev/null -w '%{http_code}\n' "https://pypi.org/pypi/acme-internal-tools/json"
PowerShell:
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.
10. Severity & Business Translation
| Technical finding | Business language | Severity |
|---|---|---|
| Publicly listable bucket, ownership-verified, credentials/db-dump objects present | 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. | HIGH |
| Cloud-native endpoint (Lambda URL / Cloud Run), owned, auth posture unconfirmed | 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.comwith subdomains api/billing/hr and probe them." → §6.1–§6.2, §6.5. - "We found a listable S3 bucket named
marketplace-mediafrom 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 vector609629065308. - "We found
123456789012as anaccountIdin 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.jsonreferences@acme/design-system, and.npmrcbinds@acmeto 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:0for@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
/apito 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), andmodules/cloud_exposure.py(cloud-native endpoint fingerprint with structural ownership gating, passive container/K8s/CI exposure flags). Deepensoffensive-osint§16.8/§16.17–16.19/§44 rather than duplicating their wordlists.
Version History
- 66ffd91 Current 2026-08-16 07:06


