Skip to content
Open
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
56 changes: 54 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@
</div>

```bash
pip install cognis-attackmap
pip install "git+https://github.com/cognis-digital/attackmap.git"
attackmap scan . # → prioritized findings in seconds
```

<!-- cognis:layman:start -->
## What is this?

ATTACKMAP takes plain-English descriptions of security alerts or incident findings and tells you exactly which MITRE ATT&CK techniques the attacker used — things like "stolen credentials," "ransomware," or "PowerShell abuse." It produces a colour-coded heatmap showing which parts of the attack lifecycle were observed and which have no coverage, so you can see your detection gaps at a glance. You can also export a layer file that loads directly into the free MITRE ATT&CK Navigator for visual exploration. It is aimed at defenders, incident responders, and detection engineers who want to quickly translate a pile of alerts into a structured ATT&CK picture without any cloud services or accounts.
<!-- cognis:layman:end -->

## Contents

- [Why attackmap?](#why) · [Features](#features) · [Quick start](#quick-start) · [Example](#example) · [Architecture](#architecture) · [AI stack](#ai-stack) · [How it compares](#how-it-compares) · [Integrations](#integrations) · [Install anywhere](#install-anywhere) · [Related](#related) · [Contributing](#contributing)
Expand Down Expand Up @@ -48,10 +54,56 @@ speak ATT&CK
<div align="right"><a href="#top">↑ back to top</a></div>

<a name="quick-start"></a>
<!-- cognis:domains:start -->
## Domains

**Primary domain:** Cyber & Security · **JTF MERIDIAN division:** NULLBYTE · SPECTER

**Topics:** `cognis` `security` `infosec` `cybersecurity` `blue-team`

Part of the **Cognis Neural Suite** — 300+ source-available tools organized across 12 domains under the JTF MERIDIAN command structure. See the [suite on GitHub](https://github.com/cognis-digital) and [jtf-meridian](https://github.com/cognis-digital/jtf-meridian) for how the pieces fit together.
<!-- cognis:domains:end -->

<!-- cognis:install:start -->
## Install

`attackmap` is source-available (not published to PyPI) — every method below installs
straight from GitHub. Pick whichever you prefer; the one-line scripts auto-detect
the best tool available on your machine.

**One-liner (Linux / macOS):**
```sh
curl -fsSL https://raw.githubusercontent.com/cognis-digital/attackmap/HEAD/install.sh | sh
```

**One-liner (Windows PowerShell):**
```powershell
irm https://raw.githubusercontent.com/cognis-digital/attackmap/HEAD/install.ps1 | iex
```

**Or install manually — any one of:**
```sh
pipx install "git+https://github.com/cognis-digital/attackmap.git" # isolated (recommended)
uv tool install "git+https://github.com/cognis-digital/attackmap.git" # uv
pip install "git+https://github.com/cognis-digital/attackmap.git" # pip
```

**From source:**
```sh
git clone https://github.com/cognis-digital/attackmap.git
cd attackmap && pip install .
```

Then run:
```sh
attackmap --help
```
<!-- cognis:install:end -->

## Quick start

```bash
pip install cognis-attackmap
pip install "git+https://github.com/cognis-digital/attackmap.git"
attackmap --version
attackmap scan . # scan current project
attackmap scan . --format json # machine-readable
Expand Down
15 changes: 13 additions & 2 deletions attackmap/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,17 +218,28 @@ def _build_parser() -> argparse.ArgumentParser:
def _load(paths, min_score) -> MapResult:
if paths:
return map_files(paths, min_score=min_score)
return map_findings(sys.stdin.read().splitlines(), min_score=min_score)
try:
data = sys.stdin.read()
except (KeyboardInterrupt, EOFError):
data = ""
return map_findings(data.splitlines(), min_score=min_score)


def _validate_min_score(value: int, parser: argparse.ArgumentParser) -> None:
"""Abort with a clear message if min_score is out of range."""
if value < 1:
parser.error(f"--min-score must be >= 1 (got {value})")


def main(argv: Sequence[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)

if args.command in ("map", "heatmap", "gap", "navigator"):
_validate_min_score(args.min_score, parser)
try:
result = _load(args.paths, args.min_score)
except OSError as exc:
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2

Expand Down
30 changes: 30 additions & 0 deletions attackmap/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from __future__ import annotations

import json
import re
from dataclasses import dataclass, field
from typing import Iterable
Expand Down Expand Up @@ -585,6 +586,8 @@ def map_findings(lines: Iterable[str], *, min_score: int = 1) -> MapResult:
def map_files(paths: Iterable[str], *, min_score: int = 1) -> MapResult:
lines: list[str] = []
for path in paths:
if not path or not path.strip():
raise ValueError(f"Invalid file path: {path!r}")
with open(path, "r", encoding="utf-8", errors="replace") as fh:
lines.extend(fh.readlines())
return map_findings(lines, min_score=min_score)
Expand Down Expand Up @@ -708,3 +711,30 @@ def navigator_layer(result: MapResult, *, name: str = "attackmap layer",
"showTacticRowBackground": True,
"hideDisabled": False,
}


# ---------------------------------------------------------------------------
# Convenience aliases used by the MCP server and external callers
# ---------------------------------------------------------------------------

def scan(text: str, *, min_score: int = 1) -> MapResult:
"""Alias for ``map_findings`` that accepts a single block of text.

Each non-blank, non-comment line in *text* is treated as one finding.
Raises ``ValueError`` if *text* is not a string.
"""
if not isinstance(text, str):
raise TypeError(f"scan() expects a str, got {type(text).__name__!r}")
return map_findings(text.splitlines(), min_score=min_score)


def to_json(result: MapResult) -> str:
"""Serialise a ``MapResult`` to a compact JSON string.

Raises ``TypeError`` if *result* is not a ``MapResult``.
"""
if not isinstance(result, MapResult):
raise TypeError(
f"to_json() expects a MapResult, got {type(result).__name__!r}"
)
return json.dumps(result.as_dict(), indent=2)
25 changes: 20 additions & 5 deletions attackmap/mcp_server.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,37 @@
"""ATTACKMAP MCP server — exposes scan() as an MCP tool for Cognis.Studio."""
from __future__ import annotations
from attackmap.core import scan, to_json
import sys
from attackmap.core import scan, to_json # noqa: F401 – re-exported aliases


def serve() -> int:
"""Start an MCP stdio server. Requires the optional 'mcp' extra:
pip install "cognis-attackmap[mcp]"
"""
try:
from mcp.server.fastmcp import FastMCP
except Exception:
print("Install the MCP extra: pip install 'cognis-attackmap[mcp]'")
from mcp.server.fastmcp import FastMCP # type: ignore[import]
except ImportError:
print(
"MCP extra not installed. Run: pip install 'cognis-attackmap[mcp]'",
file=sys.stderr,
)
return 1
except Exception as exc: # pragma: no cover
print(f"Failed to import MCP library: {exc}", file=sys.stderr)
return 1

app = FastMCP("attackmap")

@app.tool()
def attackmap_scan(target: str) -> str:
"""Map findings to MITRE ATT&CK techniques + coverage heatmap. Returns JSON findings."""
if not target or not target.strip():
return '{"error": "target must be a non-empty string"}'
return to_json(scan(target))

app.run()
try:
app.run()
except Exception as exc: # pragma: no cover
print(f"MCP server error: {exc}", file=sys.stderr)
return 1
return 0
29 changes: 29 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Comprehensive installer for cognis-digital/attackmap (Windows PowerShell).
# Tries: pipx -> uv -> pip (git+https) -> from source.
# attackmap is source-available and not on PyPI; all paths install from GitHub.
$ErrorActionPreference = "Stop"
$Repo = "attackmap"
$Url = "git+https://github.com/cognis-digital/attackmap.git"
$Git = "https://github.com/cognis-digital/attackmap.git"
function Say($m) { Write-Host "[$Repo] $m" -ForegroundColor Magenta }
function Have($c) { [bool](Get-Command $c -ErrorAction SilentlyContinue) }

if (-not (Have python) -and -not (Have py)) {
Say "Python 3.9+ is required but was not found. Install Python first."; exit 1
}
if (Have pipx) {
Say "Installing with pipx (isolated, recommended)..."
pipx install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: attackmap"; exit 0 }
}
if (Have uv) {
Say "Installing with uv..."
uv tool install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: attackmap"; exit 0 }
}
if (Have pip) {
Say "Installing with pip (user site)..."
pip install --user $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: attackmap"; exit 0 }
}
Say "No packaging tool worked; falling back to a source clone."
$Tmp = Join-Path $env:TEMP "$Repo-src"
git clone --depth 1 $Git $Tmp
Say "Cloned to $Tmp - run: cd $Tmp; python -m pip install ."
44 changes: 34 additions & 10 deletions install.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
#!/usr/bin/env sh
# Universal installer for attackmap. Prefers uv > pipx > pip; installs from the repo.
set -e
SRC="git+https://github.com/cognis-digital/attackmap.git"
echo "Installing attackmap ..."
if command -v uv >/dev/null 2>&1; then uv tool install "$SRC"
elif command -v pipx >/dev/null 2>&1; then pipx install "$SRC"
elif command -v python3 >/dev/null 2>&1; then python3 -m pip install --user "$SRC"
else echo "Need uv, pipx, or python3+pip"; exit 1; fi
echo "Done. Run: attackmap --help"
#!/usr/bin/env sh
# Comprehensive installer for cognis-digital/attackmap (Linux / macOS).
# Tries the best available method: pipx -> uv -> pip (git+https) -> from source.
# attackmap is source-available and not on PyPI; all paths install from GitHub.
set -eu

REPO="attackmap"
URL="git+https://github.com/cognis-digital/attackmap.git"
GITURL="https://github.com/cognis-digital/attackmap.git"

say() { printf '\033[1;35m[%s]\033[0m %s\n' "$REPO" "$1"; }
have() { command -v "$1" >/dev/null 2>&1; }

if ! have python3 && ! have python; then
say "Python 3.9+ is required but was not found. Install Python first."; exit 1
fi

if have pipx; then
say "Installing with pipx (isolated, recommended)..."
pipx install "$URL" && { say "Done. Run: attackmap"; exit 0; }
fi
if have uv; then
say "Installing with uv..."
uv tool install "$URL" && { say "Done. Run: attackmap"; exit 0; }
fi
if have pip3 || have pip; then
PIP="$(command -v pip3 || command -v pip)"
say "Installing with pip (user site)..."
"$PIP" install --user "$URL" && { say "Done. Run: attackmap"; exit 0; }
fi

say "No packaging tool worked; falling back to a source clone."
TMP="$(mktemp -d)"; git clone --depth 1 "$GITURL" "$TMP/$REPO"
say "Cloned to $TMP/$REPO — run: cd $TMP/$REPO && python3 -m pip install ."
87 changes: 78 additions & 9 deletions integrations/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,95 @@
Usage: <tool> scan . --format json | python integrations/webhook.py --url URL
"""
from __future__ import annotations
import argparse, json, sys, urllib.request

import argparse
import json
import sys
import urllib.request

_ALLOWED_SCHEMES = {"http", "https"}


def _validate_url(url: str, ap: argparse.ArgumentParser) -> None:
"""Abort with a clear message when *url* is not a valid http/https URL."""
if not url or not url.strip():
ap.error("--url must not be empty")
scheme = url.split("://", 1)[0].lower() if "://" in url else ""
if scheme not in _ALLOWED_SCHEMES:
ap.error(
f"--url must start with http:// or https:// (got {url!r})"
)


def _validate_header(header: str, ap: argparse.ArgumentParser) -> tuple[str, str]:
"""Parse 'Key: Value'; abort on malformed headers."""
if ":" not in header:
ap.error(
f"--header value must be in 'Key: Value' format (got {header!r})"
)
k, _, v = header.partition(":")
k, v = k.strip(), v.strip()
if not k:
ap.error(f"--header key must not be empty (got {header!r})")
return k, v


def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True)
ap.add_argument("--header", action="append", default=[], help="Key: Value")
ap = argparse.ArgumentParser(
description="Forward attackmap JSON findings to a webhook URL.",
)
ap.add_argument("--url", required=True, help="Destination URL (http/https)")
ap.add_argument(
"--header",
action="append",
default=[],
help="Extra request header in 'Key: Value' format (repeatable)",
)
args = ap.parse_args()
payload = sys.stdin.read().encode("utf-8")

_validate_url(args.url, ap)

try:
raw = sys.stdin.read()
except (KeyboardInterrupt, EOFError):
print("error: no input received on stdin", file=sys.stderr)
return 2

if not raw.strip():
print("error: stdin is empty — nothing to forward", file=sys.stderr)
return 2

# Validate that stdin looks like JSON so we catch encoding issues early.
try:
json.loads(raw)
except json.JSONDecodeError as exc:
print(f"error: stdin is not valid JSON: {exc}", file=sys.stderr)
return 2

payload = raw.encode("utf-8")
req = urllib.request.Request(args.url, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
for h in args.header:
k, _, v = h.partition(":")
req.add_header(k.strip(), v.strip())
k, v = _validate_header(h, ap)
req.add_header(k, v)

try:
with urllib.request.urlopen(req, timeout=15) as r:
print(f"posted {len(payload)} bytes -> {r.status}")
return 0
except Exception as e:
print(f"webhook error: {e}", file=sys.stderr)
except urllib.error.HTTPError as exc:
print(f"webhook HTTP error {exc.code}: {exc.reason}", file=sys.stderr)
return 1
except urllib.error.URLError as exc:
print(f"webhook connection error: {exc.reason}", file=sys.stderr)
return 1
except TimeoutError:
print("webhook error: request timed out", file=sys.stderr)
return 1
except Exception as exc: # pragma: no cover
print(f"webhook error: {exc}", file=sys.stderr)
return 1


if __name__ == "__main__":
sys.exit(main())
1 change: 1 addition & 0 deletions layman.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ATTACKMAP takes plain-English descriptions of security alerts or incident findings and tells you exactly which MITRE ATT&CK techniques the attacker used — things like "stolen credentials," "ransomware," or "PowerShell abuse." It produces a colour-coded heatmap showing which parts of the attack lifecycle were observed and which have no coverage, so you can see your detection gaps at a glance. You can also export a layer file that loads directly into the free MITRE ATT&CK Navigator for visual exploration. It is aimed at defenders, incident responders, and detection engineers who want to quickly translate a pile of alerts into a structured ATT&CK picture without any cloud services or accounts.
Loading
Loading