Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Instead of manually running separate commands and collecting notes from differen
* collect TLS certificate metadata for HTTPS services
* query A, MX, and TXT DNS records, while skipping noisy DNS lookups for IP address targets
* run endpoint discovery automatically from the `web` scan profile
* import, normalize, deduplicate, diff, and export target inventories without scanning
* generate timestamped Markdown and JSON reports under `reports/`
* highlight interesting signals for follow-up review

Expand All @@ -77,6 +78,7 @@ ActiveRecon currently supports:
| TLS | TLS version, cipher, subject, issuer, and certificate validity dates |
| DNS | Separate A, MX, and TXT lookups, with clean IP-target skip behavior |
| Web | Endpoint discovery from HTML, headers, JavaScript, robots.txt, and probes |
| Inventory | Target import, normalization, deduplication, diff, and scope export |
| Reporting | Timestamped Markdown and JSON schema `1.1` reports |
| Safety | Responsible-use notice, scope guard, dry-run mode, doctor checks |
| Analysis | Low-noise interesting signals for follow-up review |
Expand Down Expand Up @@ -149,6 +151,24 @@ Use a scope file:
activerecon --target app.example.com --scope scope.txt --scan-profile standard
```

Import target inventory without scanning:

```bash
activerecon targets import --input targets.txt --output inventories/latest.json
```

Compare two inventories without scanning:

```bash
activerecon targets diff --previous inventories/old.json --current inventories/latest.json
```

Export normalized inventory hosts to a scope file:

```bash
activerecon targets export-scope --inventory inventories/latest.json --output scopes/latest.txt
```

---

## Example Report Output
Expand Down Expand Up @@ -241,6 +261,9 @@ pip install -e .
```bash
activerecon --target <IP_OR_DOMAIN> --scan-profile <PROFILE> [--output <OUTPUT_FILE>] [--output-format md|json|both] [--verbose|--quiet]
activerecon --doctor
activerecon targets import --input <TARGETS_FILE> --output <INVENTORY_JSON>
activerecon targets diff --previous <OLD_JSON> --current <NEW_JSON>
activerecon targets export-scope --inventory <INVENTORY_JSON> --output <SCOPE_FILE>
```

### Arguments
Expand All @@ -259,6 +282,39 @@ activerecon --doctor

---

## Target Inventory

Inventory commands are intentionally separate from scanning. They do not run Nmap, HTTP checks, DNS lookups, endpoint discovery, or reports.

Supported import formats:

| Format | Behavior |
| ------- | ------------------------------------------------------------ |
| `.txt` | One target per line. Blank lines and `#` comments are ignored |
| `.json` | List of strings, list of objects, or inventory-like object |
| `.jsonl` | One string or object per line |

For JSON objects, ActiveRecon reads the first useful field from:

```text
target, url, host, domain, uri
```

Inventory files use schema version `1.0`:

```json
{
"schema_version": "1.0",
"generated_at": "2026-06-17T18:05:44Z",
"source": "targets.txt",
"targets": []
}
```

Scope export writes one normalized host per line, compatible with the current `--scope` file behavior.

---

## Config

Common config values live in:
Expand Down Expand Up @@ -420,6 +476,11 @@ ActiveRecon/
| |-- risk_analysis.py
| |-- scope_guard.py
| `-- tls_analysis.py
| |-- targets/
| | |-- parser.py
| | |-- target_diff.py
| | |-- target_inventory.py
| | `-- target_loader.py
|-- reports/
|-- tests/
|-- .github/workflows/
Expand Down
78 changes: 78 additions & 0 deletions activerecon/cli.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import argparse
import json
import logging

from .models import ReconOptions
from .modules.doctor import run_doctor
from .modules.json_report import build_json_summary
from .output_paths import DEFAULT_REPORT_DIR
from .runner import ReconValidationError, run_recon
from .targets.target_diff import diff_inventories
from .targets.target_inventory import (
build_inventory,
export_scope_file,
load_inventory,
save_inventory,
)
from .targets.target_loader import load_targets


LOG_FORMAT = "%(asctime)s - %(levelname)s - %(message)s"
Expand Down Expand Up @@ -61,6 +70,22 @@ def build_parser():
default="fast",
help="Choose a pre-defined Nmap profile from config.yaml",
)

subparsers = parser.add_subparsers(dest="command")
targets_parser = subparsers.add_parser("targets", help="Import, diff, and export target inventories")
targets_subparsers = targets_parser.add_subparsers(dest="targets_action")

import_parser = targets_subparsers.add_parser("import", help="Import targets into an inventory file")
import_parser.add_argument("--input", required=True, help="Input targets file (.txt, .json, or .jsonl)")
import_parser.add_argument("--output", required=True, help="Output inventory JSON file")

diff_parser = targets_subparsers.add_parser("diff", help="Compare two inventory files")
diff_parser.add_argument("--previous", required=True, help="Previous inventory JSON file")
diff_parser.add_argument("--current", required=True, help="Current inventory JSON file")

export_parser = targets_subparsers.add_parser("export-scope", help="Export inventory hosts to a scope file")
export_parser.add_argument("--inventory", required=True, help="Input inventory JSON file")
export_parser.add_argument("--output", required=True, help="Output scope text file")
return parser


Expand Down Expand Up @@ -103,6 +128,14 @@ def _report_paths(result):
return paths


def _inventory_host_count(inventory):
return len({
item.get("host")
for item in inventory.get("targets", [])
if item.get("host")
})


def print_report_paths(result, output=print):
paths = _report_paths(result)
if not paths:
Expand Down Expand Up @@ -141,6 +174,44 @@ def print_scan_summary(result, output=print):
output(f"- {label}: {path}")


def run_targets_command(args, output=print):
if args.targets_action == "import":
raw_targets = load_targets(args.input)
inventory = build_inventory(raw_targets, source=args.input)
save_inventory(inventory, args.output)
output("ActiveRecon target import completed")
output(f"Input: {args.input}")
output(f"Output: {args.output}")
output(f"Targets loaded: {len(raw_targets)}")
output(f"Unique targets: {len(inventory['targets'])}")
output(f"Duplicates removed: {len(raw_targets) - len(inventory['targets'])}")
output("Scans run: 0")
return 0

if args.targets_action == "diff":
previous = load_inventory(args.previous)
current = load_inventory(args.current)
diff = diff_inventories(previous, current)
output("ActiveRecon target diff completed")
output(f"Added: {len(diff['added'])}")
output(f"Removed: {len(diff['removed'])}")
output(f"Unchanged: {len(diff['unchanged'])}")
output("Scans run: 0")
return 0

if args.targets_action == "export-scope":
inventory = load_inventory(args.inventory)
export_scope_file(inventory, args.output)
output("ActiveRecon scope export completed")
output(f"Inventory: {args.inventory}")
output(f"Output: {args.output}")
output(f"Targets exported: {_inventory_host_count(inventory)}")
output("Scans run: 0")
return 0

raise ValueError("targets requires a subcommand: import, diff, or export-scope")


def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
Expand All @@ -153,6 +224,13 @@ def main(argv=None):
run_doctor(DEFAULT_REPORT_DIR)
return 0

if args.command == "targets":
try:
return run_targets_command(args)
except (OSError, ValueError, json.JSONDecodeError) as e:
parser.error(str(e))
return 2

if not args.target:
parser.error("--target is required unless --doctor is used")

Expand Down
40 changes: 40 additions & 0 deletions activerecon/targets/target_diff.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from .target_inventory import inventory_target_key
from .parser import parse_target


def _spec_from_inventory_item(item):
raw = item.get("raw") or item.get("host") or ""
target_spec = parse_target(raw)
target_spec.host = str(item.get("host") or target_spec.host).lower()
target_spec.scheme = item.get("scheme")
target_spec.port = item.get("port")
target_spec.path = item.get("path") or ""
return target_spec


def _target_map(inventory):
targets = {}
for item in inventory.get("targets", []):
if not isinstance(item, dict):
continue
target_spec = _spec_from_inventory_item(item)
targets[inventory_target_key(target_spec)] = item
return targets


def diff_inventories(previous, current):
previous_targets = _target_map(previous or {})
current_targets = _target_map(current or {})

previous_keys = set(previous_targets)
current_keys = set(current_targets)

added = [current_targets[key] for key in sorted(current_keys - previous_keys)]
removed = [previous_targets[key] for key in sorted(previous_keys - current_keys)]
unchanged = [current_targets[key] for key in sorted(current_keys & previous_keys)]

return {
"added": added,
"removed": removed,
"unchanged": unchanged,
}
91 changes: 91 additions & 0 deletions activerecon/targets/target_inventory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import json
from datetime import datetime, timezone
from pathlib import Path

from .parser import parse_target


INVENTORY_SCHEMA_VERSION = "1.0"
DEFAULT_SOURCE = "manual"


def _utc_timestamp():
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat() + "Z"


def _normalized_path(path):
if path in ("", "/"):
return ""
return str(path or "").rstrip("/")


def inventory_target_key(target_spec):
scheme = target_spec.scheme or ""
port = "" if target_spec.port is None else str(target_spec.port)
return "|".join([scheme, target_spec.host, port, _normalized_path(target_spec.path)])


def target_spec_to_dict(target_spec):
return {
"raw": target_spec.raw,
"host": target_spec.host,
"scheme": target_spec.scheme,
"port": target_spec.port,
"path": target_spec.path,
"is_ip": target_spec.is_ip,
"is_private": target_spec.is_private,
"is_loopback": target_spec.is_loopback,
}


def build_inventory(targets, source=None, generated_at=None):
seen = set()
normalized_targets = []

for raw_target in targets or []:
if not str(raw_target or "").strip():
continue
target_spec = parse_target(raw_target)
key = inventory_target_key(target_spec)
if key in seen:
continue
seen.add(key)
normalized_targets.append(target_spec_to_dict(target_spec))

return {
"schema_version": INVENTORY_SCHEMA_VERSION,
"generated_at": generated_at or _utc_timestamp(),
"source": source or DEFAULT_SOURCE,
"targets": normalized_targets,
}


def save_inventory(inventory, output_file):
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8") as f:
json.dump(inventory, f, indent=2, sort_keys=True)
f.write("\n")


def load_inventory(input_file):
input_path = Path(input_file)
with input_path.open("r", encoding="utf-8") as f:
return json.load(f)


def export_scope_file(inventory, output_file):
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
seen_hosts = set()
hosts = []

for item in inventory.get("targets", []):
host = str(item.get("host", "")).strip().lower()
if host and host not in seen_hosts:
seen_hosts.add(host)
hosts.append(host)

with output_path.open("w", encoding="utf-8") as f:
for host in hosts:
f.write(f"{host}\n")
Loading
Loading