# Domain Security Scanner: SSL, DMARC, Subdomains, Tech Stack (`s3nafps/domain-exposure-intel`) Actor

Passive security report for a domain: SSL/TLS, headers, SPF/DKIM/DMARC, subdomains, tech stack and known CVEs, scored 0-100 with fixes. Use this when vetting a vendor or your own domain.

- **URL**: https://apify.com/s3nafps/domain-exposure-intel.md
- **Developed by:** [mohamed senator](https://apify.com/s3nafps) (community)
- **Categories:** Developer tools, Automation, Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $80.00 / 1,000 full exposure reports

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

## Domain Security & Tech Stack Report (MCP) — SSL, DMARC, Subdomains, KEV

**Use this tool when** an AI agent (or a person) needs a decision-ready answer about a domain's external security posture from public data: *is this vendor/partner/target domain healthy, what should be fixed first, and what changed since last week?* One domain in, one compact, scored JSON report out — with a 0–100 score, an A–F grade, a prioritized machine-readable `fixes` array, and a `sources` list (URL + fetch time) behind every fact.

![Domain Exposure Intel demo](https://s3nafps.github.io/media/domain-exposure-intel/promo.gif)

[▶ Watch the full video](https://s3nafps.github.io/media/domain-exposure-intel/promo.mp4)

It is a normal pay-per-event (PPE) Actor — **not** Standby — so it works from the Apify MCP server (`https://mcp.apify.com`), the API, schedules and agent-payment flows. Everything is **passive**: DNS lookups, RDAP, certificate-transparency logs, one HTTPS page fetch and public vulnerability feeds. No port scanning, no brute forcing, no exploit attempts, no logins, no paywalled sources.

![Example report: a score with grade B and fixes listed critical first](https://s3nafps.github.io/media/domain-exposure-intel/domain-1.png)

### Modes (one tool per mode)

Set `mode` in the input. Each mode has its own price (see [Pricing](#pricing)).

| `mode` | Use it to… | Returns |
| --- | --- | --- |
| `full_report` | Get the whole picture in one call (due diligence, vendor risk, triage) | score + grade, `fixes`, DNS, RDAP, email, TLS/headers, tech, CVE/KEV/EPSS, subdomains |
| `tech_stack` | See what a site runs | technologies with version, confidence 0–100 and the evidence that matched |
| `subdomains` | Map the attack surface | CT-log subdomains, live/dead, takeover **candidates** |
| `email_security` | Judge spoofing/phishing exposure | SPF (lookup count vs 10), DMARC, DKIM, MTA-STS, BIMI — each pass/warn/fail with a fix |
| `tls` | Check transport security | chain validity, issuer, days to expiry, protocol, HSTS/CSP/other headers, `security.txt` |
| `diff` | Monitor on a schedule | only what changed since the last run: subdomains, certificate, tech, new KEV matches, email policy |

Vulnerability matching (`vulns`) is part of `full_report` and `diff`; it is never charged separately.

#### Calling it from an MCP client

```json
{ "tool": "<your-username>/domain-exposure-intel", "arguments": { "mode": "email_security", "domain": "example.com" } }
```

Bulk: pass `domains: ["a.com", "b.com", …]` (max 100). You get one dataset item per domain and each item is charged separately — only when it contains data.

![The six modes: full_report, tech_stack, subdomains, email_security, tls and diff](https://s3nafps.github.io/media/domain-exposure-intel/domain-2.png)

### Output at a glance

Every dataset item has:

- `outcome` — `found` | `no_match` | `unavailable` | `error`. **Only `found` is ever charged.**
- `summary` — one agent-readable paragraph.
- `score` — `{ value 0-100, grade A-F, coverage[], breakdown[] }` where a score applies. Categories that could not be evaluated are left out of the score, not counted as zero.
- `fixes[]` — `{ id, priority (critical|high|medium|low), area, title, action, evidence, impact }`, critical first.
- `sources[]` — `{ url, fetchedAt }` for every fact (each section also carries its own `sources`).
- `billing` — `{ event, priceUsd }` (or `event: null` for free results).

> The examples below were produced from the project's offline test fixtures for the **fictional domain `acme.example`**. They show the exact shape of real output (long arrays are shortened with `"..."`), not measurements of a real site.

#### `full_report` — $0.08

Input: `{ "mode": "full_report", "domain": "acme.example" }`

```json
{
  "domain": "acme.example",
  "apex": "acme.example",
  "mode": "full_report",
  "outcome": "found",
  "summary": "acme.example: exposure score 64/100 (grade D). 2 urgent fixes: jQuery 1.12.4 (detected) matches 2 known vulnerabilities, 1 in CISA KEV; Possible subdomain takeover: old-shop.acme.example; TLS cert valid, 54d to expiry, TLSv1.3; SPF pass (1/10 lookups), DMARC pass (p=quarantine); stack: WordPress 6.3.1, PHP 7.4.3, nginx 1.18.0, jQuery 1.12.4, Bootstrap 4.3.1; 8 CVEs match detected versions (1 in CISA KEV); 7 subdomains (6 live of 7 resolved), 2 takeover candidates.",
  "checkedAt": "2026-10-06T12:00:00.000Z",
  "score": {
    "value": 64,
    "grade": "D",
    "coverage": [
      "email_security",
      "tls_transport",
      "security_headers",
      "known_vulnerabilities",
      "subdomain_hygiene",
      "dns_and_registration"
    ],
    "breakdown": [
      {
        "category": "email_security",
        "points": 20,
        "max": 20
      },
      {
        "category": "tls_transport",
        "points": 17,
        "max": 17
      },
      {
        "category": "security_headers",
        "points": 5,
        "max": 14
      },
      "..."
    ]
  },
  "fixes": [
    {
      "id": "vuln-jquery",
      "priority": "critical",
      "area": "vulns",
      "title": "jQuery 1.12.4 (detected) matches 2 known vulnerabilities, 1 in CISA KEV",
      "action": "Upgrade jQuery to 3.5.0 or later, or confirm the version and any vendor backports.",
      "evidence": "KEV: CVE-2020-11022; max EPSS 23.1%",
      "impact": 10.5
    },
    {
      "id": "subdomain-takeover-old-shop.acme.example",
      "priority": "high",
      "area": "subdomains",
      "title": "Possible subdomain takeover: old-shop.acme.example",
      "action": "Remove the dangling DNS record for old-shop.acme.example (CNAME oldshop.azurewebsites.net) or re-create the Azure App Service resource under your control.",
      "evidence": "CNAME target oldshop.azurewebsites.net does not exist (NXDOMAIN); Azure App Service names can often be re-registered. Verify manually; do not claim.",
      "impact": 6
    },
    {
      "id": "headers-csp",
      "priority": "medium",
      "area": "headers",
      "title": "Content-Security-Policy missing or weak",
      "action": "Add a Content-Security-Policy (start with default-src 'self' in report-only mode).",
      "evidence": "No Content-Security-Policy header.",
      "impact": 4
    },
    {
      "...": "10 more fixes"
    }
  ],
  "sources": [
    {
      "url": "dns://acme.example?type=A,AAAA,MX,NS,TXT,CAA",
      "fetchedAt": "2026-10-06T12:00:00.000Z"
    },
    {
      "url": "https://rdap.org/domain/acme.example",
      "fetchedAt": "2026-10-06T12:00:00.000Z"
    },
    {
      "url": "dns://acme.example?type=MX,TXT",
      "fetchedAt": "2026-10-06T12:00:00.000Z"
    },
    {
      "...": "21 more"
    }
  ],
  "dns": {
    "status": "ok",
    "a": [
      "93.184.216.34"
    ],
    "mx": [
      {
        "priority": 10,
        "exchange": "mail.acme.example"
      }
    ],
    "ns": [
      "ns1.acme.example",
      "ns2.acme.example"
    ],
    "caaPresent": true,
    "...": "txt, caa, sources"
  },
  "whois": {
    "registrar": "Example Registrar, Inc.",
    "createdAt": "2016-03-14T10:12:00Z",
    "expiresAt": "2027-03-14T10:12:00Z",
    "daysToExpiry": 158,
    "transferLocked": true
  },
  "email": {
    "handlesMail": true,
    "spf": {
      "status": "pass",
      "detail": "SPF present, 1/10 lookups, ends in -all."
    },
    "dmarc": {
      "status": "pass",
      "detail": "DMARC enforced with p=quarantine and reporting enabled."
    },
    "dkim": "...",
    "mtaSts": "...",
    "bimi": "..."
  },
  "tls": {
    "certificate": {
      "issuer": "C=US, O=Let's Encrypt, CN=R11",
      "validTo": "2026-11-30T09:00:00.000Z",
      "daysToExpiry": 54,
      "chainValid": true,
      "status": "pass"
    },
    "protocol": {
      "negotiated": "TLSv1.3",
      "cipher": "TLS_AES_256_GCM_SHA384",
      "status": "pass",
      "detail": "TLS 1.3 negotiated."
    },
    "headers": {
      "hsts": {
        "status": "warn",
        "detail": "HSTS lacks includeSubDomains."
      },
      "csp": {
        "status": "fail"
      },
      "...": ""
    },
    "securityTxt": {
      "found": true,
      "status": "pass"
    }
  },
  "technologies": [
    {
      "name": "WordPress",
      "version": "6.3.1",
      "confidence": 98
    },
    {
      "name": "PHP",
      "version": "7.4.3",
      "confidence": 93
    },
    {
      "name": "nginx",
      "version": "1.18.0",
      "confidence": 85
    },
    {
      "name": "Google Analytics",
      "version": null,
      "confidence": 83
    },
    "..."
  ],
  "subdomains": {
    "totals": {
      "discovered": 7,
      "resolved": 7,
      "live": 6,
      "dead": 1,
      "wildcardNames": 1
    },
    "subdomains": [
      {
        "name": "api.acme.example",
        "status": "live",
        "ips": [
          "93.184.216.40"
        ]
      },
      {
        "name": "blog.acme.example",
        "status": "live",
        "ips": [
          "178.128.137.63"
        ],
        "cname": "acme.ghost.io"
      },
      "..."
    ],
    "takeoverCandidates": [
      {
        "subdomain": "docs.acme.example",
        "cname": "acme-docs.github.io",
        "service": "GitHub Pages",
        "label": "candidate",
        "evidence": "http_fingerprint",
        "confidence": "medium",
        "note": "GitHub Pages page for an unclaimed resource is served at docs.acme.example. Verify manually; do not claim."
      },
      "..."
    ]
  },
  "vulnerabilities": {
    "totals": {
      "findings": 8,
      "kev": 1,
      "epssAtLeast10pct": 1,
      "critical": 0,
      "high": 3
    },
    "findings": [
      {
        "id": "CVE-2020-11022",
        "technology": "jQuery",
        "detectedVersion": "1.12.4",
        "versionStatus": "detected",
        "kev": true,
        "epss": 0.231,
        "cvss": null,
        "fixedVersion": "3.5.0"
      },
      {
        "id": "CVE-2019-11358",
        "technology": "jQuery",
        "detectedVersion": "1.12.4",
        "versionStatus": "detected",
        "kev": false,
        "epss": 0.01234,
        "cvss": null,
        "fixedVersion": "3.4.0"
      },
      "..."
    ],
    "notes": [
      "Versions are inferred from banners and asset URLs: matches are \"detected\", not \"confirmed\". Backported patches can make a detected version safe."
    ]
  }
}
```

#### `tech_stack` — $0.015

Input: `{ "mode": "tech_stack", "domain": "acme.example" }`

```json
{
  "domain": "acme.example",
  "apex": "acme.example",
  "mode": "tech_stack",
  "outcome": "found",
  "summary": "acme.example: stack: WordPress 6.3.1, PHP 7.4.3, nginx 1.18.0, jQuery 1.12.4, Bootstrap 4.3.1.",
  "checkedAt": "2026-10-06T12:00:00.000Z",
  "score": null,
  "fixes": [],
  "sources": [
    {
      "url": "https://acme.example/",
      "fetchedAt": "2026-10-06T12:00:00.000Z"
    }
  ],
  "technologies": [
    {
      "name": "WordPress",
      "category": "cms",
      "version": "6.3.1",
      "confidence": 98,
      "evidence": [
        {
          "type": "meta",
          "detail": "meta generator=WordPress 6.3.1"
        },
        {
          "type": "html",
          "detail": "html /wp-content/"
        }
      ]
    },
    {
      "name": "PHP",
      "category": "language",
      "version": "7.4.3",
      "confidence": 93,
      "evidence": [
        {
          "type": "header",
          "detail": "x-powered-by: PHP/7.4.3"
        },
        {
          "type": "cookie",
          "detail": "cookie PHPSESSID"
        }
      ]
    },
    {
      "name": "nginx",
      "category": "web-server",
      "version": "1.18.0",
      "confidence": 85,
      "evidence": [
        {
          "type": "header",
          "detail": "server: nginx/1.18.0"
        }
      ]
    },
    "..."
  ],
  "pageUrl": "https://acme.example/"
}
```

Versions come from headers, `<meta name="generator">` and asset URLs. A technology without a visible version has `version: null`. The signature set (~210 entries: servers, CMSs, frameworks, CDNs, analytics, JS libraries) is **curated and written for this Actor**; no third-party fingerprint dataset is bundled, because the licences of the popular ones are either proprietary or copyleft and could not be cleared for commercial redistribution.

#### `subdomains` — $0.02

Input: `{ "mode": "subdomains", "domain": "acme.example", "maxSubdomains": 100 }`

```json
{
  "domain": "acme.example",
  "apex": "acme.example",
  "mode": "subdomains",
  "outcome": "found",
  "summary": "acme.example: exposure score 10/100 (grade F). 7 subdomains (6 live of 7 resolved), 2 takeover candidates.",
  "checkedAt": "2026-10-06T12:00:00.000Z",
  "score": {
    "value": 10,
    "grade": "F",
    "coverage": [
      "subdomain_hygiene"
    ],
    "breakdown": [
      {
        "category": "subdomain_hygiene",
        "points": 1,
        "max": 10,
        "percent": 10
      }
    ]
  },
  "fixes": [
    {
      "id": "subdomain-takeover-old-shop.acme.example",
      "priority": "high",
      "area": "subdomains",
      "title": "Possible subdomain takeover: old-shop.acme.example",
      "action": "Remove the dangling DNS record for old-shop.acme.example (CNAME oldshop.azurewebsites.net) or re-create the Azure App Service resource under your control.",
      "evidence": "CNAME target oldshop.azurewebsites.net does not exist (NXDOMAIN); Azure App Service names can often be re-registered. Verify manually; do not claim.",
      "impact": 6
    },
    "..."
  ],
  "sources": [
    {
      "url": "https://crt.sh/?q=%25.acme.example&output=json&exclude=expired",
      "fetchedAt": "2026-10-06T12:00:00.000Z"
    },
    {
      "...": "2 more"
    }
  ],
  "subdomains": {
    "discoverySource": "crt.sh",
    "totals": {
      "discovered": 7,
      "resolved": 7,
      "live": 6,
      "dead": 1,
      "wildcardNames": 1
    },
    "subdomains": [
      {
        "name": "api.acme.example",
        "status": "live",
        "ips": [
          "93.184.216.40"
        ]
      },
      {
        "name": "blog.acme.example",
        "status": "live",
        "ips": [
          "178.128.137.63"
        ],
        "cname": "acme.ghost.io"
      },
      "..."
    ],
    "takeoverCandidates": [
      {
        "subdomain": "docs.acme.example",
        "cname": "acme-docs.github.io",
        "service": "GitHub Pages",
        "label": "candidate",
        "evidence": "http_fingerprint",
        "confidence": "medium",
        "note": "GitHub Pages page for an unclaimed resource is served at docs.acme.example. Verify manually; do not claim."
      },
      {
        "subdomain": "old-shop.acme.example",
        "cname": "oldshop.azurewebsites.net",
        "service": "Azure App Service",
        "label": "candidate",
        "evidence": "cname_target_nxdomain",
        "confidence": "high",
        "note": "CNAME target oldshop.azurewebsites.net does not exist (NXDOMAIN); Azure App Service names can often be re-registered. Verify manually; do not claim."
      }
    ]
  }
}
```

Names come from certificate-transparency logs (crt.sh JSON, falling back to Cert Spotter), are de-duplicated and resolved live/dead. A **takeover candidate** is a CNAME that points at a known third-party service whose target is NXDOMAIN, or whose public page shows that service's "unclaimed" fingerprint. These are labelled `candidate` and are never verified by claiming anything — verify manually.

#### `email_security` — $0.005

Input: `{ "mode": "email_security", "domain": "acme.example" }`

```json
{
  "domain": "acme.example",
  "apex": "acme.example",
  "mode": "email_security",
  "outcome": "found",
  "summary": "acme.example: exposure score 100/100 (grade A). SPF pass (1/10 lookups), DMARC pass (p=quarantine), DKIM pass, MTA-STS pass, BIMI not published.",
  "checkedAt": "2026-10-06T12:00:00.000Z",
  "score": {
    "value": 100,
    "grade": "A"
  },
  "fixes": [
    {
      "id": "email-bimi",
      "priority": "low",
      "area": "email",
      "title": "BIMI not published or inactive",
      "action": "Optional: publish default._bimi TXT with an SVG logo (needs DMARC p=quarantine/reject at pct=100).",
      "evidence": "BIMI not published (optional brand-logo standard).",
      "impact": 0
    }
  ],
  "sources": [
    {
      "url": "dns://acme.example?type=MX,TXT",
      "fetchedAt": "2026-10-06T12:00:00.000Z"
    },
    {
      "...": "6 more"
    }
  ],
  "email": {
    "spf": {
      "status": "pass",
      "lookups": 1,
      "qualifier": "-all",
      "detail": "SPF present, 1/10 lookups, ends in -all."
    },
    "dmarc": {
      "status": "pass",
      "policy": "quarantine",
      "detail": "DMARC enforced with p=quarantine and reporting enabled."
    },
    "dkim": {
      "status": "pass",
      "detail": "DKIM key(s) published on selector(s): google."
    },
    "mtaSts": {
      "status": "pass",
      "mode": "enforce",
      "detail": "MTA-STS enforced (max_age 604800s)."
    },
    "bimi": {
      "status": "warn",
      "detail": "BIMI not published (optional brand-logo standard).",
      "fix": "Optional: publish default._bimi TXT with an SVG logo (needs DMARC p=quarantine/reject at pct=100)."
    }
  }
}
```

SPF lookups are counted recursively through `include`/`redirect` against the RFC 7208 limit of 10. DKIM is checked on ~26 common selectors (add yours with `dkimSelectors`); "no key found" is a `warn`, not a `fail`, because selectors are not enumerable.

#### `tls` — $0.005

Input: `{ "mode": "tls", "domain": "acme.example" }`

```json
{
  "domain": "acme.example",
  "apex": "acme.example",
  "mode": "tls",
  "outcome": "found",
  "summary": "acme.example: exposure score 71/100 (grade C). TLS cert valid, 54d to expiry, TLSv1.3; 2/6 security headers present; security.txt found.",
  "checkedAt": "2026-10-06T12:00:00.000Z",
  "score": {
    "value": 71,
    "grade": "C"
  },
  "fixes": [
    {
      "id": "headers-csp",
      "priority": "medium",
      "area": "headers",
      "title": "Content-Security-Policy missing or weak",
      "action": "Add a Content-Security-Policy (start with default-src 'self' in report-only mode).",
      "evidence": "No Content-Security-Policy header.",
      "impact": 4
    },
    {
      "id": "headers-xfo",
      "priority": "medium",
      "area": "headers",
      "title": "Clickjacking protection missing",
      "action": "Send X-Frame-Options: DENY (or CSP frame-ancestors 'none').",
      "evidence": "No X-Frame-Options or CSP frame-ancestors.",
      "impact": 2
    },
    "..."
  ],
  "sources": [
    {
      "url": "tls://acme.example:443",
      "fetchedAt": "2026-10-06T12:00:00.000Z"
    },
    {
      "url": "https://acme.example/",
      "fetchedAt": "2026-10-06T12:00:00.000Z"
    },
    {
      "...": "2 more"
    }
  ],
  "tls": {
    "certificate": {
      "issuer": "C=US, O=Let's Encrypt, CN=R11",
      "validTo": "2026-11-30T09:00:00.000Z",
      "daysToExpiry": 54,
      "chainValid": true,
      "status": "pass"
    },
    "protocol": {
      "negotiated": "TLSv1.3",
      "status": "pass"
    },
    "httpToHttps": {
      "status": "pass",
      "detail": "HTTP 301 redirect to HTTPS.",
      "redirectsTo": "https://acme.example/"
    },
    "headers": {
      "hsts": {
        "present": true,
        "status": "warn"
      },
      "csp": {
        "present": false,
        "status": "fail"
      },
      "xFrameOptions": {
        "present": false,
        "status": "fail"
      },
      "xContentTypeOptions": {
        "present": true,
        "status": "pass"
      },
      "referrerPolicy": {
        "present": false,
        "status": "warn"
      },
      "permissionsPolicy": {
        "present": false,
        "status": "warn"
      }
    },
    "securityTxt": {
      "found": true,
      "status": "pass",
      "contact": [
        "mailto:security@acme.example"
      ],
      "expires": "2027-06-30T00:00:00.000Z"
    }
  }
}
```

#### `diff` — $0.02 (first baseline free)

Input: `{ "mode": "diff", "domain": "acme.example" }` — run it weekly with an Apify Schedule and keep the same `snapshotStoreName`.

- First run: stores a snapshot in a named key-value store and returns `outcome: "found"` with `diff.baseline: true` — **free**.
- Later runs: return only changes. No changes → `outcome: "no_match"` — **free**. Changes → `outcome: "found"` — charged.
- If a data source is down during a later run, that section is skipped (listed in `diff.skippedSections`) and its previous snapshot value is kept, so a flaky network never produces fake "removed" changes.

```json
{
  "domain": "acme.example",
  "apex": "acme.example",
  "mode": "diff",
  "checkedAt": "2026-10-06T12:00:00.000Z",
  "outcome": "found",
  "summary": "acme.example: 4 changes since 2026-10-06 — DMARC changed: p=quarantine -> p=none (weaker).",
  "score": {
    "value": 55,
    "grade": "F"
  },
  "fixes": [
    {
      "id": "email-dmarc",
      "priority": "medium",
      "area": "email",
      "title": "DMARC not enforced for acme.example",
      "action": "Review aggregate reports, then move to p=quarantine and p=reject.",
      "evidence": "DMARC policy is p=none (monitoring only); spoofed mail is not blocked.",
      "impact": 4
    },
    "..."
  ],
  "diff": {
    "baseline": false,
    "changes": [
      {
        "type": "email_dmarc_changed",
        "severity": "high",
        "summary": "DMARC changed: p=quarantine -> p=none (weaker)"
      },
      {
        "type": "subdomain_added",
        "severity": "info",
        "summary": "New subdomain vpn.acme.example"
      },
      {
        "type": "cert_renewed",
        "severity": "info",
        "summary": "Certificate renewed (new expiry 2027-01-10)"
      },
      {
        "type": "tech_version_changed",
        "severity": "info",
        "summary": "nginx version 1.18.0 -> 1.20.2"
      }
    ],
    "counts": {
      "subdomain_added": 1,
      "cert_renewed": 1,
      "tech_version_changed": 1,
      "email_dmarc_changed": 1
    },
    "skippedSections": []
  }
}
```

### Pricing

Pay-per-event. You pay only when an item has `outcome: "found"`. Unreachable domains, empty results, errors and "no changes" cost $0.

| Mode | Event | Price per domain |
| --- | --- | --- |
| `full_report` | `full-report` | $0.08 |
| `tech_stack` | `tech-stack` | $0.015 |
| `subdomains` | `subdomain-scan` | $0.02 |
| `email_security` | `email-security` | $0.005 |
| `tls` | `tls-check` | $0.005 |
| `diff` | `diff-report` | $0.02 |

When is an item "found" (charged)?

- `full_report`: at least 3 of the 6 base sections (DNS, RDAP, email, TLS, tech, subdomains) returned data.
- `tech_stack`: the page was fetched and at least one technology matched.
- `subdomains`: at least one subdomain was found in CT logs.
- `email_security`: DNS answered for the domain (a missing SPF/DMARC record is a finding, and is charged).
- `tls`: a TLS handshake or the HTTPS page fetch succeeded.
- `diff`: at least one change since the previous snapshot (baseline and no-change runs are free).

**Spending limits.** The Actor respects your run's *maximum cost*. When the limit is reached it stops cleanly: the item that would exceed it is not delivered or charged, remaining domains are returned as `outcome: "unavailable"` with `stoppedReason: "max_total_charge_reached"`, and the run summary says so.

### Input

| Field | Default | Max | Notes |
| --- | --- | --- | --- |
| `mode` | `full_report` | | see above |
| `domain` | | | one domain/URL (agent shortcut) |
| `domains` | | 100 items | bulk list |
| `maxDomains` | 25 | 100 | cap on processed domains |
| `maxSubdomains` | 100 | 500 | how many CT subdomains to resolve |
| `includeVulns` | `true` | | CVE matching in `full_report`/`diff` |
| `dkimSelectors` | | 20 items | extra selectors |
| `snapshotStoreName` | `domain-exposure-snapshots` | | diff mode |
| `updateBaseline` | `true` | | diff mode |
| `maxConcurrency` | 5 | 10 | |
| `requestTimeoutSecs` | 10 | 30 | every request has a timeout and is retried with backoff |

Empty input runs a fast demo on `example.com`.

### Data sources and etiquette

DNS (system resolver, DNS-over-HTTPS fallback), [RDAP via rdap.org](https://rdap.org), [crt.sh](https://crt.sh) with [Cert Spotter](https://sslmate.com/certspotter/) fallback, one HTTPS fetch of `/` (plus `/.well-known/security.txt`, MTA-STS policy and an HTTP→HTTPS redirect check), [OSV.dev](https://osv.dev) (JS libraries, Drupal, Laravel), [NVD](https://nvd.nist.gov/developers/vulnerabilities) CPE matching (server software, WordPress, PHP…), the [CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) feed and [FIRST EPSS](https://www.first.org/epss/). Vulnerability lookups are cached in a named key-value store.

- Sends a descriptive `User-Agent` (`DomainExposureIntelBot/…`) and honours `robots.txt` for page fetches (if a site disallows `/`, tech/header results are `unavailable`). `/.well-known/` standards files are fetched as machine-readable protocol endpoints.
- Target hosts are checked so private/reserved addresses are never fetched (SSRF guard, re-checked on every redirect).
- TLS is a normal handshake on port 443 only; nothing else is probed.

Optional environment variables (set them as secret env vars in Actor settings; **none are required**): `NVD_API_KEY` (higher NVD rate limit and more lookups per domain), `CERTSPOTTER_API_KEY`.

### Limitations

- **Versions are "detected", never "confirmed".** They come from banners, meta tags and asset URLs; vendors backport patches, and many sites hide versions. CVE matches are leads to verify, not proof of vulnerability.
- Only versions exposed on the **homepage and its headers** are seen; no JavaScript is executed and no other pages are crawled, so client-rendered or login-gated stacks are under-reported.
- Subdomain discovery depends on **certificate-transparency logs**: hosts that never had a public certificate are invisible. crt.sh is occasionally slow or down (Cert Spotter is the fallback; both unavailable ⇒ `unavailable`, not charged). Very large domains are truncated to `maxSubdomains` resolved names.
- Takeover results are **candidates** from CNAME/NXDOMAIN and public error-page fingerprints; false positives and negatives are possible.
- DKIM can only be checked on known selectors. MTA-STS/BIMI checks do not validate the BIMI logo or VMC.
- TLS reports the protocol negotiated with this client; it does not enumerate every supported protocol or cipher.
- NVD without an API key is throttled (about 5 requests / 30 s) and capped at 4 product lookups per domain; set `NVD_API_KEY` to raise it. Large bulk `full_report` runs are therefore slower.
- `diff` compares against the previous run's snapshot in your account; deleting the store resets the baseline (free again).
- Public data only: domains behind login, or sites that block robots, produce partial or `unavailable` results.

### Related Actors

- [Google Trends Scraper: Bulk Compare 100+ Keywords](https://apify.com/s3nafps/google-trends-bulk-compare): compare search interest in a company or brand with its competitors, up to 500 names on one Google Trends scale.
- [YouTube Transcripts for AI](https://apify.com/s3nafps/youtube-transcripts-for-ai): bulk YouTube transcripts from videos, channels and search as text, Markdown and RAG chunks.

### Feedback

Something not working, or a feature you need? Open an issue in the **Issues** tab with your input and I'll take a look. If the Actor saves you time, a short review on its Store page helps other people find it.

### Local development

```bash
npm ci
npm run build && npm run lint && npm test     # offline unit tests with fixtures
npm run smoke                                  # live: runs every mode against 3 real domains and prints the outputs
apify run                                      # default input = example.com demo
```

# Actor input Schema

## `mode` (type: `string`):

Pick one. full_report ($0.08): everything in one scored report with a prioritized fixes array (use for due diligence, vendor risk, 'is this domain healthy?'). tech_stack ($0.015): technologies with version, confidence, evidence. subdomains ($0.02): certificate-transparency subdomains, live/dead, takeover candidates. email_security ($0.005): SPF/DKIM/DMARC/MTA-STS/BIMI with fixes (use for spoofing/phishing risk). tls ($0.005): certificate, protocol, HSTS/CSP and other headers, security.txt. diff ($0.02, first baseline free): only what changed since the last run of this mode for the domain (use on a weekly schedule). Nothing is charged when the result has no data (outcome no_match, unavailable or error).

## `domain` (type: `string`):

One domain or URL, e.g. example.com or https://www.example.com/path. Shortcut for agents; for several domains use 'domains'. Subdomains are accepted: TLS and tech use the host as given, DNS/RDAP/email/subdomain checks use its registrable domain.

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

List of domains to check. One dataset item is produced per domain, each charged separately when it contains data. At most 100 entries (also capped by 'Max domains').

## `maxDomains` (type: `integer`):

Safety cap on how many domains from the list are processed (max 100).

## `maxSubdomains` (type: `integer`):

Subdomains found in certificate transparency logs are all counted, but only this many are resolved live/dead and checked for takeover (max 500).

## `includeVulns` (type: `boolean`):

In full_report and diff, map detected technology versions to CVEs (OSV.dev, NVD), CISA KEV and EPSS. Versions are 'detected', never 'confirmed'.

## `dkimSelectors` (type: `array`):

Additional DKIM selectors to check besides ~26 common ones (e.g. a custom selector used by your mail provider). Max 20.

## `snapshotStoreName` (type: `string`):

Named key-value store that holds one snapshot per domain for diff mode. Keep the same name across scheduled runs.

## `updateBaseline` (type: `boolean`):

In diff mode, replace the stored snapshot with the current state after reporting changes. Turn off to keep comparing against a fixed baseline.

## `maxConcurrency` (type: `integer`):

How many domains are checked in parallel (max 10).

## `requestTimeoutSecs` (type: `integer`):

Timeout for each network request; every request is retried with backoff (max 30).

## Actor input object example

```json
{
  "mode": "full_report",
  "domains": [
    "example.com"
  ],
  "maxDomains": 25,
  "maxSubdomains": 100,
  "includeVulns": true,
  "snapshotStoreName": "domain-exposure-snapshots",
  "updateBaseline": true,
  "maxConcurrency": 5,
  "requestTimeoutSecs": 10
}
```

# Actor output Schema

## `reports` (type: `string`):

One compact JSON item per domain: outcome (found | no_match | unavailable | error), summary, score, prioritized fixes and sources.

## `runSummary` (type: `string`):

Counts of processed, charged and free items, and whether the max-charge limit stopped the run.

# 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": [
        "example.com"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("s3nafps/domain-exposure-intel").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": ["example.com"] }

# Run the Actor and wait for it to finish
run = client.actor("s3nafps/domain-exposure-intel").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": [
    "example.com"
  ]
}' |
apify call s3nafps/domain-exposure-intel --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,s3nafps/domain-exposure-intel"
        }
    }
}
```

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/QBIQNOudoR1Pgv7z4/builds/L3zWWL8MUXRPeRXsr/openapi.json
