# Subdomain Takeover & Certificate Expiry Radar (`engraved_peanut/subdomain-takeover-cert-expiry-radar`) Actor

Find subdomain takeover risks and expiring TLS certificates before attackers or outages do. Discovery from Certificate Transparency logs, CNAME chain analysis, live TLS probes and dangling DNS detection, with one clean JSON record per host for security, DevOps and MSP teams.

- **URL**: https://apify.com/engraved_peanut/subdomain-takeover-cert-expiry-radar.md
- **Developed by:** [Abhishek Kumar](https://apify.com/engraved_peanut) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 host audits

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Subdomain Takeover & Certificate Expiry Radar

Live attack-surface audit for security, DevOps and MSP teams. Give it root domains; it discovers every subdomain from public Certificate Transparency logs, resolves DNS over HTTPS, probes TLS and flags the issues that cause real outages and breaches:

- **Subdomain takeover**: CNAME pointing at an unclaimed GitHub Pages, Heroku, S3, Azure, Shopify, Netlify and similar resource, confirmed by the provider's "unclaimed" page.
- **Dangling CNAME**: the CNAME target no longer exists in DNS (NXDOMAIN).
- **Invalid or expired certificates**: failed validation, hostname mismatch, expired chain.
- **Expiring certificates**: configurable warning window, default 30 days.

Every result is checked live at run time. No mock data, no cached lists.

### What counts as a subdomain takeover or certificate risk

| Finding type | Severity | Detection rule |
|---|---|---|
| `subdomain_takeover` | critical | The CNAME points at a known hosting provider **and** that provider serves its "unclaimed resource" page (GitHub Pages, Heroku, AWS S3, Azure App Service, Shopify, Netlify, Webflow, Zendesk, Tumblr, UserVoice and others) |
| `certificate_expired` | critical | The TLS handshake fails and the served certificate's `notAfter` is in the past |
| `dangling_cname` | high / critical | The CNAME chain ends at a name that no longer exists in DNS (NXDOMAIN) - anyone can register it. Critical when the target belongs to a known provider |
| `invalid_certificate` | high | Validation fails for any other reason: hostname mismatch, incomplete chain, unknown issuer |
| `certificate_expiring` | medium / high | The certificate expires inside your `expiryWarningDays` window; high when 7 days or fewer remain |
| - | info | Host resolves, TLS validates, certificate is outside the warning window |

Every row is produced by a live DNS-over-HTTPS walk plus a live TLS handshake at run time - no cached lists, no synthetic data. Hosts that do not resolve are skipped and never charged.

### Who uses it

- Security teams running scheduled external attack-surface monitoring.
- MSPs and agencies watching client domains.
- M\&A and due-diligence analysts screening a target's hygiene.
- AI agents (via MCP) answering "is any subdomain of X at risk?".

### Pricing (pay per event)

| Event | Price | Charged when |
|---|---|---|
| Host audited | $0.01 | A discovered host resolves and answers a probe, and its record is saved |
| Verified risk finding | $0.12 | The record has medium, high or critical severity |

Healthy hosts cost one cent. Turn on **Return only risky hosts** to pay only for findings. Hosts that do not resolve are never charged.

### Input

| Field | Type | Default | Description |
|---|---|---|---|
| `domains` | string\[] | `["mozilla.org"]` | Root domains to audit (only ones you are authorised to test) |
| `maxHostsPerDomain` | integer | 40 | Cap on hosts audited per domain |
| `expiryWarningDays` | integer | 30 | Flag certificates expiring within N days |
| `onlyRisks` | boolean | false | Skip healthy hosts |
| `concurrency` | integer | 10 | Parallel audits (1-30) |
| `probeTimeoutSecs` | integer | 8 | TLS connect timeout |
| `proxyConfiguration` | object | none | Optional proxy for HTTP and discovery |

### Output sample

```json
{
  "host": "crash-pings.mozilla.org",
  "rootDomain": "mozilla.org",
  "severity": "medium",
  "findings": [
    {
      "type": "certificate_expiring",
      "severity": "medium",
      "detail": "Certificate expires in 21 days (2026-11-16T10:07:59+00:00)."
    }
  ],
  "findingTypes": ["certificate_expiring"],
  "cnameChain": ["mozilla-it.netlifyglobalcdn.com"],
  "thirdPartyService": null,
  "resolvedIps": ["35.190.14.201"],
  "httpStatus": null,
  "tlsValid": true,
  "certIssuer": "Google Trust Services",
  "certExpiresAt": "2026-11-16T10:07:59+00:00",
  "certDaysLeft": 21,
  "checkedAt": "2026-10-26T12:36:51.690890+00:00"
}
```

### Integrations

**Python**

```python
from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("USERNAME/subdomain-takeover-cert-expiry-radar").call(run_input={"domains": ["example.com"], "onlyRisks": True})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["host"], item["severity"], item["findingTypes"])
```

**Node.js**

```javascript
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: '<APIFY_TOKEN>' });
const run = await client.actor('USERNAME/subdomain-takeover-cert-expiry-radar').call({ domains: ['example.com'], onlyRisks: true });
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items);
```

**cURL**

```bash
curl -X POST "https://api.apify.com/v2/acts/USERNAME~subdomain-takeover-cert-expiry-radar/run-sync-get-dataset-items?token=<APIFY_TOKEN>" \
  -H 'Content-Type: application/json' \
  -d '{"domains": ["example.com"], "onlyRisks": true}'
```

**MCP / AI agents**: add this Actor through the Apify MCP server (`https://mcp.apify.com?actors=USERNAME/subdomain-takeover-cert-expiry-radar`). Prompt: "Use the radar to list risky subdomains of example.com with certificates expiring in 14 days." Schedule it daily with `onlyRisks: true` and connect a webhook to Slack.

### Notes and limits

- Subdomains come from Certificate Transparency (Cert Spotter, merged with crt.sh). Hosts that never had a public certificate are not discovered.
- Takeover detection needs both a known-provider CNAME and that provider's unclaimed-resource page; unknown providers are reported only as dangling CNAMEs.
- Severity: `critical` = confirmed takeover or expired certificate, `high` = dangling CNAME or invalid certificate or expiry within 7 days, `medium` = expiry inside the warning window, `info` = healthy.
- Only audit domains you own or are authorised to test.

### Run and verify locally

Requires Python 3.9 or newer. The Docker image runs 3.12, and the suite stays 3.9-compatible because macOS ships 3.9 as `/usr/bin/python3`.

The Makefile picks `uv` when it is installed and falls back to a plain `venv` otherwise:

```bash
make setup
make test
make validate
make run
```

Manual setup with uv (recommended on macOS - Homebrew's `python3` may be newer than the dependencies support):

```bash
uv python install 3.12
uv venv .venv --python 3.12
source .venv/bin/activate
uv pip install -r requirements-dev.txt
```

Manual setup without uv, using Apple's system Python 3.9 (always present, never PEP 668 managed):

```bash
/usr/bin/python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt
```

Then confirm the interpreter before running anything:

```bash
which python3
python -m pytest
apify validate-schema
apify run --purge
ls storage/datasets/default | wc -l
```

`which python3` must print the path inside `.venv`. If it prints `/opt/homebrew/bin/python3` or `/usr/bin/python3`, the shell is not activated and `apify run` will fail with `ModuleNotFoundError: No module named 'httpx'`. The record count should be around 40 for the default `mozilla.org` input.

To verify billing locally without the platform:

```bash
ACTOR_TEST_PAY_PER_EVENT=true apify run
ls storage/datasets/charging_log
```

### Deploy

```bash
apify login
apify push
```

Then in Apify Console open **Publication > Monetization**, choose **Pay per event** and add the two events from `.actor/pay_per_event.json` (`host-audited` $0.01, `risk-finding` $0.12). Publish, then schedule the Actor daily with `onlyRisks: true` and a webhook to Slack for new findings.

The full Console walkthrough - listing copy, event configuration, synthetic-event handling, quality gates and a 90-day growth plan - lives in [PUBLISHING.md](./PUBLISHING.md).

# Actor input Schema

## `domains` (type: `array`):

Root domains to audit, e.g. example.com. Only audit domains you own or have permission to test.

## `maxHostsPerDomain` (type: `integer`):

Upper bound on discovered subdomains audited per root domain. Also caps your spend.

## `expiryWarningDays` (type: `integer`):

Hosts whose certificate expires within this many days are flagged as a risk finding.

## `onlyRisks` (type: `boolean`):

If enabled, healthy hosts are skipped and not charged. Ideal for scheduled monitoring.

## `concurrency` (type: `integer`):

Number of hosts audited in parallel (1-30).

## `probeTimeoutSecs` (type: `integer`):

Connection timeout for TLS probes.

## `proxyConfiguration` (type: `object`):

Optional proxy for HTTP and discovery requests. Direct TLS probes always originate from the Actor.

## Actor input object example

```json
{
  "domains": [
    "mozilla.org"
  ],
  "maxHostsPerDomain": 40,
  "expiryWarningDays": 30,
  "onlyRisks": false,
  "concurrency": 10,
  "probeTimeoutSecs": 8,
  "proxyConfiguration": {}
}
```

# Actor output Schema

## `auditResults` (type: `string`):

Dataset with one row per audited host: severity (info/medium/high/critical), finding types (subdomain_takeover, dangling_cname, certificate_expired, invalid_certificate, certificate_expiring), CNAME chain, third-party service, issuer, certificate expiry and days left.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "domains": [
        "mozilla.org"
    ],
    "maxHostsPerDomain": 40,
    "expiryWarningDays": 30
};

// Run the Actor and wait for it to finish
const run = await client.actor("engraved_peanut/subdomain-takeover-cert-expiry-radar").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "domains": ["mozilla.org"],
    "maxHostsPerDomain": 40,
    "expiryWarningDays": 30,
}

# Run the Actor and wait for it to finish
run = client.actor("engraved_peanut/subdomain-takeover-cert-expiry-radar").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "domains": [
    "mozilla.org"
  ],
  "maxHostsPerDomain": 40,
  "expiryWarningDays": 30
}' |
apify call engraved_peanut/subdomain-takeover-cert-expiry-radar --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,engraved_peanut/subdomain-takeover-cert-expiry-radar"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/JxhhFwWPTFr3K2ixH/builds/LQsxGaiI8Jb9Uqmlf/openapi.json
