Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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 de
.claude/skills/elementalsouls-cloud-saas-exposure/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 922% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 416% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 580% | 0% |
| case-07 | ✗→✓ | ▲ Improved | 530% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 358% | 0% |
> 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:
not be the target's — the same "active but low-intrusion" tier as any other GET against target-adjacent infra. Standard engagement authorization applies.
lawfully holding the leaked key.
npm/PyPI registries — zero packets to the target. This is why it's explicitly in scope even though it's "active."
already resolved by earlier recon — no new network traffic of its own.
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.
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.
ownership-verified. An unattributable public hit is not the client's risk — see the ownership-gated model, §6.3.
dependency-confusion check. Confirmation only — §8.7.
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.
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.
the rule of three (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 —
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.
{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.net
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.
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)
(§6.1)
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.
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):
bashNAME="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):
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):
bashAPEX="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):
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.
number.
sts:AssumeRole error-message differential ("notauthorized" 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.
role-assumption pretext.
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) >> 7key (reject).
Published by Aidan Steele / Tenable; reference implementation credit to A. Frichetten (hackingthe.cloud).
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())
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:
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:
powershellfunction 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.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.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-02 | fail→pass | 14,167 | 32,740 | +131% | 1 | 1 | 0% | 1,482 | 15,148 | +922% | 0 | 0 | — |
case-03 | fail→pass | 24,567 | 34,610 | +41% | 1 | 1 | 0% | 3,402 | 17,554 | +416% | 0 | 0 | — |
case-04 | fail→fail | 13,755 | 13,418 | -2% | 1 | 1 | 0% | 1,428 | 12,735 | +792% | 0 | 0 | — |
case-05 | fail→pass | 25,109 | 14,068 | -44% | 1 | 1 | 0% | 2,072 | 14,086 | +580% | 0 | 0 | — |
case-06 | fail→fail | 8,459 | 11,563 | +37% | 1 | 1 | 0% | 1,390 | 12,853 | +825% | 0 | 0 | — |
case-07 | fail→pass | 13,196 | 13,233 | +0% | 1 | 1 | 0% | 2,111 | 13,290 | +530% | 0 | 0 | — |
case-08 | pass→pass | 16,079 | 6,605 | -59% | 1 | 1 | 0% | 1,813 | 12,991 | +617% | 0 | 0 | — |
case-09 | pass→pass | 7,955 | 9,830 | +24% | 1 | 1 | 0% | 1,395 | 12,642 | +806% | 0 | 0 | — |
case-10 | fail→pass | 20,987 | 21,415 | +2% | 1 | 1 | 0% | 2,873 | 13,144 | +358% | 0 | 0 | — |
case-11 | fail→pass | 17,560 | 12,158 | -31% | 1 | 1 | 0% | 2,082 | 13,187 | +533% | 0 | 0 | — |
case-12 | pass→pass | 12,277 | 3,990 | -68% | 1 | 1 | 0% | 1,286 | 12,530 | +874% | 0 | 0 | — |
case-13 | pass→pass | 21,745 | 13,902 | -36% | 1 | 1 | 0% | 2,958 | 13,405 | +353% | 0 | 0 | — |
case-14 | fail→pass | 10,482 | 6,790 | -35% | 1 | 1 | 0% | 1,620 | 12,993 | +702% | 0 | 0 | — |
case-15 | pass→pass | 19,395 | 3,907 | -80% | 1 | 1 | 0% | 2,375 | 12,464 | +425% | 0 | 0 | — |
case-16 | pass→pass | 16,562 | 7,977 | -52% | 1 | 1 | 0% | 1,660 | 13,213 | +696% | 0 | 0 | — |
case-17 | pass→pass | 24,141 | 7,525 | -69% | 1 | 1 | 0% | 3,239 | 13,158 | +306% | 0 | 0 | — |
case-18 | pass→pass | 14,364 | 11,206 | -22% | 1 | 1 | 0% | 1,665 | 12,854 | +672% | 0 | 0 | — |
case-19 | fail→pass | 17,893 | 10,249 | -43% | 1 | 1 | 0% | 1,894 | 12,694 | +570% | 0 | 0 | — |
case-20 | pass→pass | 20,148 | 5,085 | -75% | 1 | 1 | 0% | 2,752 | 12,652 | +360% | 0 | 0 | — |
case-21 | fail→pass | 20,189 | 21,640 | +7% | 1 | 1 | 0% | 2,025 | 13,333 | +558% | 0 | 0 | — |
case-22 | pass→pass | 21,622 | 6,849 | -68% | 1 | 1 | 0% | 2,251 | 12,953 | +475% | 0 | 0 | — |
case-01 | fail→pass | 21,774 | 15,974 | -27% | 1 | 1 | 0% | 2,992 | 14,382 | +381% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +45 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.