A template-driven DAST and application validation tool, built to run as a step in a deployment pipeline rather than as a standalone scanner someone kicks off by hand.
After every deploy, there are really two questions worth asking: did the app actually come up working, and did this deploy introduce anything security-relevant. Most pipelines answer neither in a structured way - a smoke check here, a scanner report nobody opens there, no single gate that actually blocks a bad deploy.
Mantis answers both with one command and one exit code. Smoke tests answer "does it work." Templates, a crawler with passive checks, and OpenAPI-driven API tests answer "is it secure." Detection is deterministic on purpose - no model in the loop deciding whether something's a finding. A template either matches or it doesn't, so a given target and a given template set always produce the same result.
YAML templates in the Nuclei style: request chaining, five matcher types
(status/word/regex/json/dsl), three extractor types (json/regex/header),
and a small DSL for matcher/assertion expressions - parsed with Go's own
go/parser instead of a third-party expression library. 22 starter
templates ship in templates-community/ (see mantis templates list).
Payload fuzzing. A payloads block defines named value lists that get
substituted into {{name}} placeholders in any request field. Three attack
modes: sniper (one set, one value at a time), pitchfork (multiple sets
zipped in lockstep), and clusterbomb (full Cartesian product of all sets).
Every combination runs the full request chain independently.
Conditional steps. A when field on any request takes a DSL expression
evaluated against variables extracted by earlier steps. A false result skips
that request without failing the chain, so a template can branch on what it
actually found rather than what it assumed.
Flow scripting. The optional flow field replaces the default sequential
chain with a small scripting language: http(N) calls requests by index,
if/else/end branches on conditions (including httpN_status,
httpN_matched, httpN_body after each call), set name = expr stores a
value for later steps and {{name}} rendering, and stop terminates early
keeping any findings produced so far.
Global matchers. A passive layer runs against every HTTP response in every template execution - no extra requests. Eight built-in patterns catch secrets and noise that templates don't explicitly look for: AWS keys, private key material, JWTs, SQL error messages, stack traces, PHP errors, and ASP.NET errors. Findings from this layer are reported separately from template-match findings.
A crawler with inline passive checks (missing security headers, insecure cookies, CORS misconfiguration, server version disclosure, directory listing) running against every page it fetches, followed by active testing: the loaded template set against the target root, plus per-parameter fuzzing (SQL injection, XSS, path traversal, and a couple more classes) against every discovered query parameter - always GET-only and safe to retry. Fuzzing can optionally extend to form submissions too, but that's opt-in and explained in Environment policy and destructive testing below, not something that happens by default.
YAML workflows that chain HTTP requests through extracted variables, with JSONPath and DSL assertions, best-effort cleanup requests, and dependencies between workflows.
Point it at an OpenAPI 3 (or Swagger 2) spec and it generates checks for missing authentication, undeclared HTTP method acceptance, broken object level authorization, and sensitive-looking keys in response bodies.
BOLA gets two modes depending on what auth is configured. With a single
identity it's a heuristic: two different sample IDs both returning 200
under the same credentials, flagged at reduced confidence since it doesn't
actually prove anything. With two or more identities configured in
environments.yaml (each with a known owned resource id via owns), it's
a real check: identity A requesting a resource identity B is known to own,
and getting a 200 back, is a confirmed finding at full confidence - not a
guess, because the test fixtures tell Mantis exactly who owns what.
Environments resolve to a security level (aggressive/standard/passive)
that controls how deep testing goes - crawl depth, rate limits, whether
active testing runs at all. Any environment name that isn't found, or has
no recognized security_level, resolves to passive, the safest policy -
a typo should never grant more access than intended. Full details,
including the double opt-in required for anything destructive, are in
Environment policy and destructive testing.
mantis validate runs smoke + passive DAST + active DAST + optional API
tests according to the target environment's policy, then makes a single
pass/fail decision with a real exit code. Before running any checks, every
command does a baseline reachability request first - a target that's
completely unreachable fails loudly with an operational error instead of
silently running zero checks and reporting a clean pass.
Console, JSON, SARIF 2.1.0, JUnit XML, HTML, a native Azure DevOps format
that streams ##vso[...] logging commands directly to a pipeline's
console (findings show up as real entries in the Issues panel, no
marketplace extension needed), and a native GitHub Actions format that
emits ::error/::warning/::notice annotations plus a markdown job
summary. --report takes a comma-separated list (junit,sarif,html,azdo
or junit,sarif,html,github) so one scan produces every output you need.
cmd/mantis/ CLI entry point and command wiring
internal/
httpclient/ the one HTTP execution path - scope, rate limits,
redirects, response size caps, secret redaction
templates/ YAML parsing, matchers, extractors, request chaining
dsl/ the matcher/assertion expression language
jsonpath/ minimal JSONPath used by matchers and extractors
dast/ crawler + passive checks + active scan orchestration
globalmatchers/ passive secret/error patterns evaluated on every response
attacks/ per-parameter fuzzing (sqli/xss/path-traversal/ssti/cmdi)
smoke/ smoke workflow parsing and execution
api/ OpenAPI reader + generated API security checks
environments/ environment profiles and security-level policy
gate/ the pass/fail decision
findings/ the shared finding type
reporters/ console/json/sarif/junit/html/azdo/github output
templates-community/ starter security templates
smoke/ example smoke workflow
ci/ example pipeline integrations (Azure Pipelines, GitHub Actions)
Two external dependencies (gopkg.in/yaml.v3, golang.org/x/net/html).
Everything else - the HTTP client, the matcher/extractor engine, the DSL,
the OpenAPI reader, SARIF/JUnit output - is hand-rolled to keep this light
rather than dragging in a pile of transitive dependencies for what are,
individually, fairly small problems.
- Grab the archive for your platform from the
latest release:
mantis_<version>_linux_amd64.tar.gz(or_arm64),mantis_<version>_darwin_amd64.tar.gz(or_arm64for Apple Silicon), ormantis_<version>_windows_amd64.zip(or_arm64). - Extract it. Each archive contains the
mantisbinary,templates-community/,README.mdandLICENSE:tar -xzf mantis_0.1.0_linux_amd64.tar.gz cd mantis_0.1.0_linux_amd64 - (Recommended) verify the download against
checksums.txtfrom the same release before running it:sha256sum -c checksums.txt --ignore-missing
- Confirm it works:
./mantis version # mantis v0.1.0 (commit ..., built ...) - Optionally put it on your
PATH- but keeptemplates-community/next to wherever you actually runmantisfrom. It's the default--templates-dir, and every template-driven command needs it unless you point--templates-dirsomewhere else.
docker pull ghcr.io/bogdanticu88/mantis:latest
docker run --rm ghcr.io/bogdanticu88/mantis:latest versionThe image is built from a distroless base with templates-community/
baked in at /app, so the default relative paths work with no extra
mounting - docker run --rm ghcr.io/bogdanticu88/mantis:latest scan <target> --fail-on high works immediately. For an environments file or
custom templates, mount them in and point the flags at the mounted path:
docker run --rm -v "$(pwd)":/data ghcr.io/bogdanticu88/mantis:latest \
validate --environment test --environments-file /data/environments.yamlRequires Go 1.23+.
git clone https://github.com/bogdanticu88/Mantis.git
cd Mantis
go build -o mantis ./cmd/mantis
./mantis versionA four-step walkthrough. Replace <target> throughout with your own
application's URL, or a target you're explicitly authorized to test.
mantis scan <target> --fail-on highThis loads every template under templates-community/ (22 ship by
default - mantis templates list to see them) and reports anything that
matches. Real output looks like this:
MANTIS Security Gate
Environment:
Target: <target>
Findings
✗ [HIGH] Spring Boot Actuator Exposure (GET /actuator/env)
✗ [MEDIUM] Cookie Set Without Secure/HttpOnly Flags (GET /)
✗ [LOW] HTTP TRACE Method Enabled (TRACE /)
CRITICAL: 0
HIGH: 1
MEDIUM: 1
LOW: 1
INFO: 0
Pipeline: FAILED
Exit code: 1
--fail-on high means anything HIGH or worse trips the gate (exit code
1) - that's what a pipeline step checks. Nothing found, or everything
below the threshold, exits 0.
mantis dast <target> --fail-on highSame idea, but it crawls first (discovers pages, forms, query parameters), layers passive checks (missing headers, insecure cookies, CORS) on every page it fetches, then runs the templates plus query-parameter fuzzing (SQLi/XSS/path traversal and more) against everything it found - this invocation alone never submits any form or touches anything destructive; that needs an explicit opt-in, see Environment policy and destructive testing.
Create smoke/health.yaml:
id: health
type: smoke
steps:
- id: check
request:
method: GET
path: /
assertions:
- status: 200mantis smoke --target <target> --workflows-dir smokeThis is the "does it actually work" half of Mantis, unrelated to
security - it just confirms the deployment came up. See
smoke/payments-lifecycle.yaml for a fuller example with request
chaining, variable extraction, and cleanup.
Copy environments.example.yaml to environments.yaml and point
dev/test/etc. at your real base URLs and security_level. Then:
mantis validate --environment test --fail-on high \
--report junit,sarif,html --output-dir ./reportsThis is what actually goes in CI: smoke + passive DAST + active DAST
(plus API tests if you add --openapi), gated by the target
environment's policy, collapsed into one pass/fail exit code. See
ci/azure-pipelines.example.yml or ci/github-actions.example.yml for a
complete pipeline step block, or --report github/--report azdo for
native annotations in either platform.
Every scan/dast/smoke/api/validate command accepts --environment,
--environments-file, --fail-on critical|high|medium|low|any,
--report (comma-separated: json,sarif,junit,html,azdo,github),
--output/--output-dir, --insecure-skip-verify, --timeout. Flags
can go before or after the positional target.
Everything above - scan, dast, smoke, validate as shown in Getting started - only ever reads. It's safe to run against anything you're authorized to point it at. This section covers the one part of Mantis that isn't: destructive testing, which is opt-in, off by default, and worth understanding fully before you turn it on.
Environments resolve to a security level that controls how deep testing goes:
| Level | Smoke | Passive DAST | Active DAST | Destructive |
|---|---|---|---|---|
| aggressive | Yes | Yes | Yes | Yes* |
| standard | Yes | Yes | Yes | No |
| passive | Yes | Yes | No | No |
* Authorized in principle, not run automatically - see below.
Any environment name that isn't found, or has no recognized
security_level, resolves to passive, the safest policy. A typo in an
environment name should never grant more access than intended.
Two mechanisms, both of which send real, state-changing requests against your target:
- API method-abuse probing (
mantis api scan) tries HTTP methods your OpenAPI spec doesn't declare for a path - POST, PUT, PATCH, DELETE - to see if the server accepts them anyway. - Form fuzzing (
mantis dast/mantis validate) submits any non-GET form the crawler finds, with an injection payload in one field at a time. That's a real POST hitting real business logic - if the target doesn't reject the malformed input, it creates an actual record (a comment, an order, whatever the form does) containing the payload. The payloads themselves are chosen to be low-impact (echo markers, arithmetic markers, notDROP TABLE), but creating a real record is still a real side effect, not a simulated one.
Query-parameter fuzzing (covered in Getting started, step 2) is unaffected by any of this - it's GET-only, always safe, and runs whenever active testing is on regardless of the destructive setting.
security_level: aggressive in environments.yaml authorizes
destructive testing - it does not run it. mantis api scan, mantis dast, and mantis validate all additionally require an explicit
--destructive flag on that specific invocation:
mantis validate --environment dev --destructiveWithout --destructive, an aggressive environment still runs full
crawl depth, every template, and query-parameter fuzzing - everything
except the two destructive mechanisms above. One line in a config file
is never enough, by itself, to make a future pipeline run start
submitting forms; someone has to type the flag on the run that should.
If you want full crawl/template/rate-limit depth without ever allowing
destructive testing on a given environment - even if --destructive gets
added to a pipeline step by mistake later - set allow_destructive: false
explicitly, which overrides the level's default:
environments:
test:
base_url: https://test.example.com
security_level: aggressive
allow_destructive: falseOnly turn this on against environments where creating throwaway records is genuinely fine. Never against production. Think carefully before using it against a shared test environment other people's work depends on.
make build # go build -o mantis ./cmd/mantis
make test # go test ./... -v
make vet # go vet ./...
make fmt # gofmt -l . (lists unformatted files)
make templates # build, then validate every template in templates-community/
make clean # remove built binaries and stray report filesEvery package has unit test coverage, including cmd/mantis itself (flag
parsing, exit codes, command wiring - main() is split into a testable
dispatch()/exitCodeFor() so tests don't need to shell out to a
subprocess), the DAST crawler (scope enforcement, depth/request limits,
form extraction), the fuzzing engine, and the API package's generated
checks (missing-auth, method-abuse, both BOLA modes, sensitive-data
detection).
environments.yaml maps environment names to a base URL, a
security_level, and optional authentication (bearer, basic, or
oauth2 client-credentials). An environment can also declare identities
- a second set of credentials plus an
ownsmap of resource ids, which is what turns the API BOLA check from a heuristic into a real one (seeenvironments.example.yamlfor a worked example):
application:
name: Payments API
environments:
dev:
base_url: https://dev.example.com
security_level: aggressive
production:
base_url: https://api.example.com
security_level: passive
authentication:
type: bearer
token: ${MANTIS_TOKEN}Templates (templates-community/*.yaml): id, info
(name/severity/tags/description/remediation/cwe/owasp), optional
variables, and a requests chain. Each request has method, path,
optional headers/body, matchers, and extractors. Matchers support
status, word, regex, json ($.a.b[0] style paths), and dsl
(status_code == 200 && contains(body, 'foo') - functions: contains,
starts_with, ends_with, len, regex, to_lower, to_upper,
header(name)). Variables use ${name} or {{name}} placeholders in
path/headers/body.
payloads (optional) defines named value lists; attack sets the
combination mode (sniper / pitchfork / clusterbomb). Each request
can carry a when DSL expression to skip it conditionally based on
variables from earlier steps.
flow (optional) replaces the sequential chain with an explicit script.
Statement reference:
http(N) — run request N (1-indexed); populates http<N>_status
(int), http<N>_body (string), http<N>_matched (bool)
if <expr> — DSL condition; any variable or httpN_* is available
else — optional alternative branch
end — closes an if block
set name = <expr> — evaluate expression and store result; {{name}} renders it
stop — terminate the flow, keep findings collected so far
# comment — ignored
Example:
flow: |
http(1)
if http1_matched
http(2)
if http2_status == 200 && contains(http2_body, "admin")
http(3)
end
endSmoke workflows (smoke/*.yaml): an ordered list of steps, each with
a request, assertions (status, path + exists/equals, or a raw
dsl expression), and optional extract. cleanup requests always run
last, best-effort. Workflows can depends_on other workflows by id.
Every finding carries the request/response exchange(s) that produced it.
Sensitive headers and any resolved secret values are redacted before a
finding is ever constructed (see internal/httpclient/redact.go) - secrets
never reach a report or log line.
- Environment drift detection - compare the same finding across dev/test/acc/prod and flag regressions introduced between environments
- GitLab Code Quality / SAST report format (GitHub and Azure DevOps both have native reporters now; GitLab still only gets file-based SARIF/JUnit)
- More fuzzing payloads/classes (SSRF-via-parameter, XXE, NoSQL injection) once the current 5-class set has proven itself low-noise in practice
- GraphQL support past a raw introspection template
- WebSocket testing, browser-based/JS-driven crawling
MIT - see LICENSE.
