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.
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).
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:
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).
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.
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.
--validate.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.--validate --validate-cloud active tier, out of scope.osint-methodology §2) or the explicit ownership signals in §6.3/§7.7/§9.1.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 —
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.{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:
*.s3(.<region>).amazonaws.com,
*.storage.googleapis.com, or *.blob.core.windows.netOne 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.
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:
--company)| 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.
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.
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
doneProbe (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
doneCandidate 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" } }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.
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.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.
[A-Z2-7]{16}.z.account = (z & 0x7FFFFFFFFF80) >> 7Published by Aidan Steele / Tenable; reference implementation credit to A. Frichetten (hackingthe.cloud).
#!/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())ASIAY34FZKBOKMUTVV7A -> 609629065308Verify any reimplementation against this before trusting its output.
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:aws[a-z0-9\-]*:[a-z0-9\-]*:[a-z0-9\-]*:(\d{12}):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.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.
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.
| 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 |
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.
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:
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.
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 userThe 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.
| 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.
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).
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).
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.
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."
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.
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.
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.
| 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) |
Drop these into a fresh session to verify the skill loads and routes correctly.
acme.com with subdomains api/billing/hr and probe them." → §6.1–§6.2, §6.5.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.acme-employee-payroll.csv." → §6.3 branded-object-key ownership signal — now verified, promote to CRITICAL.ASIAY34FZKBOKMUTVV7A." → §7.3–§7.5, test vector 609629065308.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.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.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.*.run.app. Is that Cloud Run endpoint owned or unverified?" → §9.1 — CNAME provenance = owned./api to confirm auth posture — that's stage-6 --validate --validate-cloud.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.13d9204
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.