# Running an AI-first external attack surface assessment with CVEngine

> Source: <https://dev.to/qunirice_verase_1ab0caeba/running-an-ai-first-external-attack-surface-assessment-with-cvengine-2i29>
> Published: 2026-11-05 10:17:34+00:00

I spend a lot of time on the least glamorous part of security work: keeping an accurate list of everything we actually expose to the internet. This is the loop I now run end to end — scope, inventory, an AI-assisted assessment in CVEngine, then hands-on triage. A first pass takes 60 to 90 minutes and sits at intermediate level: you should be comfortable with a shell, DNS and basic HTTP.

Two things before any command. First, get written authorisation for every asset in scope — scanning hosts you do not own is illegal in most jurisdictions, and "we thought it was ours" is not a defence. Second, three terms I use throughout: the *attack surface* is every host, subdomain, IP address and open port you expose; *ASM* is the ongoing job of discovering those assets and keeping the list current; *triage* is deciding which findings are real and which come first.

Where I could not confirm a CVEngine-specific menu label, API path, scan-profile name or token format from the source material, I mark it `<verify in documentation>`. Those values vary between deployments — read the API reference of your own version rather than guessing.

`nmap` installs natively on Windows, but the pipeline below assumes a POSIX shell.`subfinder`, `httpx`, `dig`, `jq` and `curl`. No version pins here — check with `--version` after installing.`<verify in documentation>`.
On Debian or Ubuntu:

```
sudo apt update
sudo apt install -y dnsutils nmap jq curl
go install -v github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest
go install -v github.com/projectdiscovery/httpx/cmd/httpx@latest
export PATH="$PATH:$HOME/go/bin"
```

That last line does more than it looks. `go install` drops binaries into `$HOME/go/bin`, which is not on `PATH` by default; skip the export and you get `command not found`, which reads like a broken install. Confirm each tool prints usage text rather than an error:

```
subfinder -version
httpx -version
nmap --version | head -n 1
```

Every later step inherits these assets — the CVEngine project, the AI ranking, the report. Build an inventory before writing the scope down and you will eventually scan a subsidiary nobody asked about.

```
mkdir -p ~/asm-run/{raw,out} && cd ~/asm-run
cat > scope.yaml <<'EOF'
organisation: Example Corp
in_scope:
  - example.com
  - api.example.com
  - 203.0.113.0/24
out_of_scope:
  - billing.example.com
window: "2024-01-15 02:00Z/2024-01-15 06:00Z"
contact: security@example.com
EOF
```

In this example `203.0.113.0/24` is the reserved documentation range — swap in a range you actually own. `billing.example.com` is excluded because a payment provider hosts it and forbids third-party scanning.

```
dig +short example.com A
subfinder -d example.com -all -o raw/subdomains.txt
sort -u raw/subdomains.txt -o raw/subdomains.txt
wc -l raw/subdomains.txt

httpx -l raw/subdomains.txt -ports 443,80,8443 -status-code -title \
      -tech-detect -json -o raw/http.json
```

You get one JSON object per responding host:

```
{"input":"api.example.com","url":"https://api.example.com","status_code":401,"title":"Unauthorized","tech":["nginx"]}
```

Sanity check: `wc -l raw/subdomains.txt` should be non-zero, and `jq -s length raw/http.json` should return fewer entries than the subdomain list. That gap is expected — only hosts answering on those ports appear. If the count is zero, DNS resolution failed.

Then flatten it into the CSV that CVEngine ingests:

```
jq -r -s '.[] | [.input, .url, (.status_code|tostring), (.tech[0] // "unknown")] | @csv' \
   raw/http.json > out/inventory.csv
```

The platform's value is correlation. A stale `nginx` banner on an internet-facing box is not a finding by itself; the same box with an exposed admin path and an end-of-life component is. Doing that matching by hand across hundreds of hosts does not scale.

In the UI: **Projects → New project → Import targets → CSV upload**, then map the columns:

| CSV column | CVEngine field | Notes | 
|---|---|---|
| `host` | target / hostname | required, one per row | 
| `url` | endpoint | scheme + host + port | 
| `status` | metadata | informational, not a finding | 
| `tech` | technology tag | improves AI correlation | 

If you would rather automate it, the API pattern looks like this — endpoint path, payload keys and auth header are placeholders, so confirm each one against your version's API reference (`<verify in documentation>`):

```
export CVENGINE_URL="https://cvengine.example.com"   # your instance
export CVENGINE_TOKEN="<token-from-your-admin>"

curl -sS -X POST "$CVENGINE_URL/api/v1/projects" \
  -H "Authorization: Bearer $CVENGINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"external-asm-example-corp","targets_file":"out/inventory.csv","profile":"external"}'
```

Most deployments treat this request as idempotent per project name, so a re-run after a scope change updates the target list instead of creating duplicates — verify that on your own instance rather than assuming it. Check what landed:

```
curl -sS "$CVENGINE_URL/api/v1/projects" -H "Authorization: Bearer $CVENGINE_TOKEN" \
  | jq -r '.[] | "\(.id) \(.name) \(.targets_count)"'
```

You want a line like `42 external-asm-example-corp 318`, where 318 equals `wc -l out/inventory.csv` minus the header row. A `401` here means the token is wrong, not that the project is missing.

Poll until the run reaches a completed state, then pull the findings:

```
curl -sS "$CVENGINE_URL/api/v1/projects/42/assessments" \
  -H "Authorization: Bearer $CVENGINE_TOKEN" \
  | jq -r '.[] | "\(.id) \(.status) \(.started_at)"'

curl -sS "$CVENGINE_URL/api/v1/assessments/<assessment-id>/findings" \
  -H "Authorization: Bearer $CVENGINE_TOKEN" -o out/findings.json

jq -r '.[] | [.severity, .host, .title] | @tsv' out/findings.json | sort | head -n 20
```

The top of that list should read like a prioritised queue — highest severity first, host and title legible:

```
critical   api.example.com    Exposed administrative interface
high       www.example.com    Outdated TLS configuration
```

If CVEngine runs on hardware you control, size and harden that box before the first scan. The same deployment hygiene you would apply to any self-hosted service applies here too.

AI ranking orders your queue; it does not replace confirmation. A finding that survives a five-second manual check is a finding. One that does not is noise, and noise costs you the team's trust in the tool. So open the top entries by hand — hit the endpoint, read the TLS configuration, look for that exposed admin path — and only then assign an owner.

The scanner is the cheap part. What keeps an external attack surface assessment honest is the order of operations: written scope first, inventory second, AI correlation third, human confirmation last. Get that sequence right and a 90-minute run produces a queue your team can actually act on; skip a step and you produce a large file nobody trusts.

*This article was originally published on AI Release — [https://ai-release.org](https://ai-release.org) — and the channel at [https://t.me/ai_release1](https://t.me/ai_release1) has more walkthroughs like this one.*
