PRAETOR
Multi-engine static security analysis for code, repos, and AI agent skills.
PRAETOR fuses five complementary security engines into a single prioritized,
deduplicated, false-positive-filtered report - human-readable and JSON. It is
built as a Claude Code skill but
the scanner (scripts/praetor.py) is a standalone Python CLI that runs anywhere.
It is static: it reads files and never executes, imports, installs, builds,
or evaluates the code it scans. Its own code makes no outbound network calls; the
optional SCA/Semgrep backends query advisory/registry databases (the SCA
pip-audit path runs with --disable-pip so even a hostile requirements.txt
is never built -- see references/LIMITS.md).
Why PRAETOR
Most scanners do one thing. PRAETOR combines five lenses and, crucially, adds an interpretation layer that turns N raw tool outputs into one coherent, ranked answer - the part that usually separates a useful security review from a wall of noise.
It also covers an attack surface almost no classic scanner does: the AI-security / agent-supply-chain threats that a self-improving agent or an LLM-in-the-loop pipeline actually faces - prompt injection hidden in docs, invisible-Unicode instruction smuggling, data-exfiltration patterns, and dangerous auto-run hooks.
The five engines
| Engine | Finds | Backend | Requires |
|---|---|---|---|
| sast | Bundled pinned coverage for Python, JavaScript and TypeScript .ts/.tsx; optional registry packs may add findings in other languages but do not establish pinned coverage |
Semgrep (OSS) + bundled offline rules | semgrep (native / WSL / Docker) |
| secrets | Hardcoded API keys & tokens (AWS, GCP, GitHub, Slack, Stripe, OpenAI, Anthropic, Google, Twilio, SendGrid, npm, JWT), PEM private keys, DB connection-string passwords, base64-wrapped secrets, high-entropy strings | built-in (stdlib) | nothing |
| sca | Known-vulnerable dependencies with CVE/GHSA IDs, severity, and upgrade path | osv-scanner -> pip-audit -> npm audit |
one of those (optional) |
| aisec | Prompt-injection payloads, invisible-Unicode / Trojan Source smuggling, data exfiltration, dangerous auto-run hooks (Claude Code, Cursor, Windsurf, Cline / git / npm lifecycle), safety-bypass instructions | built-in (stdlib) | nothing |
| model | Dangerous globals (os.system, subprocess.*, builtins.eval, pickle gadget-chain components, ...) referenced inside .pt/.pth/.ckpt/.pkl/.pickle/.npy/.npz/.h5/.hdf5/.keras/.bin/.joblib/.dill files, via pickle-opcode disassembly (pickletools.genops() -- never pickle.load); a bounded heuristic for Keras Lambda-layer RCE in HDF5; .safetensors recognized as safe-by-design |
built-in (stdlib) | nothing |
The secrets, aisec, and model engines are pure Python standard library and
always run. If a SAST or SCA backend is missing, that engine reports
unavailable / [BLIND]; an explicit --fail-on gate refuses that scan.
Install
⚠️ PRAETOR is NOT on PyPI. pip install praetor-security does not work and never has —
the name is unregistered. (Do not reach for pip install praetor either: that name belongs
to an unrelated project.) Install from source:
git clone https://github.com/GrowBridge-LLC/praetor-security
cd praetor-security
pip install . # provides the `praetor` command
praetor --version
Or without installing anything at all — see below.
PRAETOR has no runtime dependencies — deliberately. A tool that vets other
people's dependencies should not arrive with a large dependency tree of its own.
The secrets and AI-security engines are pure standard library and work
immediately; the SAST and SCA engines shell out to external binaries only if you
install them, and report themselves unavailable rather than silently returning
zero findings if you do not.
You can also run it straight from a clone with no install at all:
python scripts/praetor.py <target>
Optional engines
Only Python 3.8+ is strictly required. Install the optional engines for full coverage:
# SAST engine (Semgrep). Runs natively on macOS and Linux. Prefer an isolated
# environment (pipx / venv) to avoid disturbing other packages:
#
# ⚠️ WINDOWS: a native `pip install semgrep` installs a launcher that exits 1
# with no output -- semgrep-core is not built for native Windows. Use WSL or
# Docker (see below). PRAETOR reports this honestly as [error] or [BLIND]
# rather than as a clean scan, but the sast engine WILL be unavailable until
# you provide one of those runtimes.
pipx install semgrep # or: pip install semgrep
# SCA engine (osv-scanner - preferred, language-agnostic):
winget install Google.OSVScanner # Windows
brew install osv-scanner # macOS
# or download a release binary: https://github.com/google/osv-scanner/releases
# Fallbacks (auto-detected): pip-audit (Python) or npm audit (Node), no install
# needed if you already have pip-audit or npm.
If Semgrep will not run natively on your platform, PRAETOR can invoke it via WSL
or Docker with --semgrep-runtime wsl|docker.
Usage
# Full scan, human-readable report
python scripts/praetor.py /path/to/target
# JSON for CI or another tool, written to a directory
python scripts/praetor.py /path/to/target --format json --out ./praetor-out
# Vet an untrusted skill/plugin with just the built-in engines (no external tools)
python scripts/praetor.py /path/to/skill --engines aisec,secrets
# Fully offline (bundled Semgrep rules only, no registry fetch)
python scripts/praetor.py /path/to/target --no-registry --engines sast,secrets,aisec
# Works from a clone and from a pip install alike; set PRAETOR_RULES_DIR to
# point at a different ruleset location.
# CI gate: non-zero exit if anything HIGH or worse is found
python scripts/praetor.py /path/to/target --fail-on HIGH --format json
Options
| Option | Meaning |
|---|---|
--engines |
Comma list of sast,secrets,sca,aisec,model (default: all) |
--format |
text, json, both, or sarif (default: text) |
--out DIR |
Write praetor-report.txt / .json / .sarif to DIR |
--min-severity |
Hide active findings below this level |
--fail-on |
Exit 1 if any active finding is at/above this level; exit 3 if the scan was not measured |
--allow-degraded |
Accept a degraded scan's blind spot instead of exit 3, with or without --fail-on |
--sca-backend |
auto (default), osv, pip-audit, npm |
--semgrep-runtime |
auto (default), native, wsl, docker |
--no-registry |
Bundled Semgrep rules only; no network fetch |
--semgrep-config |
Extra Semgrep --config (repeatable) |
--exclude REGEX |
Exclude matching relative paths (repeatable) |
--max-file-size |
Skip files larger than N bytes (default 3 MB) |
Exit codes: 0 no active findings at or above --fail-on — NO FINDING, never SAFE, 1 findings
at/above --fail-on, 2 usage/internal error, 3 the scan was not measured enough to pass. An enabled engine error or unrecognised status exits 3 even without --fail-on: report-only automation must not proceed after the scanner itself broke. An unavailable runtime is the deliberate report-only carve-out (it remains [BLIND] and exits 0 by default), but it still exits 3 under --fail-on. Zero files examined and PRAETOR/Semgrep scope disagreement also exit 3 under --fail-on. In every case the stderr names which and what to change.
🔴 3 exists because "no findings" and "nothing ran" are not the same result.
An engine that dies produces zero findings for a reason that has nothing to do
with the target being clean, and until that reached the exit code a broken
semgrep runtime looked exactly like a passing scan. Pass --allow-degraded to
gate on findings alone when you knowingly accept the blind spot. 1 outranks
3, and both are non-zero, so a gate testing if rc != 0 fails safe either way.
Output
Every finding includes: severity x confidence, engine, location, category, CWE/OWASP mapping, a concrete fix, and a reference. Findings are sorted most-dangerous-first. Likely false positives are moved to a separate FILTERED section with a stated reason (never dropped silently). Detected secrets are redacted - PRAETOR never prints a live credential.
The JSON report is a stable schema (schema_version) suitable for a CI job, an
apply-gate, or another agent to consume.
The engine-status contract — read this before trusting exit 0
report["meta"]["engines"] is an object keyed by engine name (sast, secrets, sca, aisec, model).
Each value has a status field, one of: ok, not-applicable, no-coverage,
disabled, unavailable, error, partial-parse. no-coverage means PRAETOR
identified a language for which this release has no pinned SAST rules. It is a
named gap, never PASS and never silent; its detail is one or more deterministic
phrases such as SAST: NO COVERAGE (shell). unavailable, error, and
partial-parse are blind spots — the engine did not produce a trustworthy
answer for that scan. A consumer matching on this list by name must treat any
unrecognised word as a blind spot, never as a pass.
🔴 Without --fail-on, a blind engine still exits 0. This is the deliberate report-only
carve-out described above, and it means a consumer that checks only the process exit code cannot
tell "clean" from "half the engines never ran." Both look identical from the outside: exit 0,
some findings or none. If your integration reads the report and decides anything based on it, check
meta["engines"][name]["status"] for every engine you care about — do not infer engine health from
the exit code or the finding count alone. (--fail-on closes this specific gap for you automatically,
by turning any blind spot into exit 3 — but only if you pass it.)
schema_version 5.0 — SAST names languages without pinned coverage
meta.engines.sast.status can now be no-coverage. SAST eligibility is the set
of languages declared by the bundled pinned rules, not a separately maintained
extension allowlist. In this release those rules cover Python, JavaScript and
TypeScript .ts/.tsx; .cts/.mts remain a named typescript-module gap
because the pinned Semgrep runtime does not open them. A shell-only change produces:
[GAP] sast SAST: NO COVERAGE (shell)
This is a deliberate, trustworthy statement about the pinned ruleset, so it
does not become exit 3; it is not an ok/PASS result either. On a mixed
Python/shell target, Python is scanned and the text report uses [ran+GAP] with
the shell phrase appended to the engine detail. SARIF carries each gap as a
warning tool-execution notification.
Registry packs and --semgrep-config may add findings, but they never enlarge
the pinned eligibility set or erase this gap. A missing, unreadable or malformed
bundled ruleset is an engine error and fails closed. Consumers that exhaustively
match status words must add no-coverage; that wire change is why this is schema
major 5. The pinned-shell-rules follow-up is
issue #2.
schema_version 4.3 — the whole-scan measurement, exposed
meta.scope.walked_nothing — true when no file was opened at all. It is
what exit code 3 and SARIF's executionSuccessful: false are both computed
from, published so a consumer does not have to re-derive a safety question.
Additive; a 4.2 consumer ignores it.
🔴 A zero finding count means nothing when this is true. Read it alongside
meta.engines[<name>].status before treating any clean result as a result.
⚠️ This key shipped before the version moved, and that is worth knowing if you
pinned 4.2. For a few hours two different reports both declared themselves 4.2
and one carried a key the other did not. Nothing in the test suite related the
report's contents to the number describing them; 646 tests passed over the
mismatch. tests/test_schema_version_tracks_the_report_keys.py now pins the key
set of every fixed surface per version, so it cannot recur silently.
schema_version 4.2 — a cross-scan identity for every finding
Every finding gains a fingerprint: a stable identity that survives the
finding MOVING within its file. Additive; a 4.1 consumer ignores it.
🔴 fingerprint and dedup_key answer different questions and are not
interchangeable.
| Field | Question it answers | Includes the line? |
|---|---|---|
dedup_key |
are these the same finding in this scan? | yes, deliberately |
fingerprint |
is this the same finding as one in a previous scan? | no, deliberately |
If you are building anything that compares two scans — a dashboard, a CI job
that gates only on newly-introduced findings, a triage record that must stay
attached to its finding — key on fingerprint. Keying on dedup_key, or on
file:line, reports a wall of false "new findings" on any commit that adds an
import above them.
The basis is (rule_id, file, normalised snippet). It deliberately ignores
severity and confidence, so a rule re-rating does not orphan an existing triage
record: the issue did not change, the tool's opinion of it did.
⚠️ Two identical lines in one file share a fingerprint. Without a line number
there is nothing to tell them apart. Group by fingerprint and count. SARIF's own
partialFingerprints has the same property for the same reason.
⚠️ The snippet is redacted at the Finding boundary before the fingerprint is
computed, so a fingerprint can be stored and transmitted without carrying a
credential. tests/test_finding_fingerprint.py asserts that.
schema_version 4.1 — two additive report sections
MINOR, not major, and the distinction is the point: nothing was renamed, removed or re-typed. A 4.0 consumer keeps working and simply ignores two new top-level keys.
chains— an array of attack chains: findings that COMPOSE into one path. Always present; an empty array means no chain matched, which (like an empty findings list) is not a statement that the tree is safe. Each entry carrieschain_id,title,severity,proximity,scope,why_it_composes,what_to_verify, andlinks. 🔴 Readproximitybefore you act onseverity.same-filemeans both links came from one file — real evidence of composition.same-treemeans only that both appeared somewhere in the scan, which in a large repository is close to certain; those chains are capped at MEDIUM and are a prompt to look, not an escalation.scopenames the file (or says the whole tree).capability_profile— one entry per capability dimension, answering "if I open this repository in an agent right now, what have I authorised?" Each carriesstatus(present/none— neversafe),evidence_count,production_evidence_count,test_or_example_evidence_count,worst_severity, and up to threeexamples. ⚠️ It is computed from ACTIVE findings, so it inherits every miss they have. A capability reportednonemeans no rule matched one.
Both sections only ADD. Neither suppresses, downgrades or re-buckets a finding,
and neither changes an exit code. See references/LIMITS.md for what each one
does not claim.
schema_version 4.0 — a new SAST status word
meta.engines.sast.status can now be partial-parse: semgrep ran, and every
error it reported was PartialParsing — some source in the target could not
be parsed. If you match engine status by an exhaustive list rather than a
"is it ok" check, add this value to whatever set you treat as blocking.
- Still a blind spot, deliberately, even though most of the file measured
cleanly. The exit code, the
[BLIND]report mark, and--fail-on's refusal to certify the scan clean are all identical to a plainerror— onlymeta.engines.sast.detailis more specific. An earlier version of this addition treated it as a lesser carve-out (likeunavailable) and was reverted after an adversarial audit demonstrated a live exploit: unlikeunavailable, which the environment chooses,partial-parseis chosen by the scanned tree — a file missing one bracket decides, on its own, whether SAST reports itself degraded, and a report-only run must refuse it exactly as it refuseserror. - Not a new exit code, not a new report mark. Nothing else in this section changes.
schema_version 3.0 — engine statuses split, and a second file count
Two breaking changes had shipped under an unchanged 2.0. If you consume the
JSON, check schema_version before these:
| what | 2.0 | 3.0 |
|---|---|---|
| an engine with nothing to scan | unavailable |
not-applicable |
| an engine that could not scan | unavailable |
unavailable (unchanged) |
| files the secrets engine read | (not reported) | meta.secret_file_count |
unavailablewas two facts under one word. "No dependency manifests here" and "the SCA backend is missing" are different, and a gate must treat them differently — one is a clean answer, the other is a blind spot. If you key onunavailable, you now wantnot-applicablefor the benign case.meta.file_countno longer covers every finding. The secrets engine deliberately walks wider than the others (vendored and build directories are skipped for SAST but scanned for credentials), so a finding can be reported in a filefile_countnever counted. Usemeta.secret_file_countalongside it. ⚠️ Treatingfile_count == 0as "nothing was scanned" is now wrong.
schema_version 2.0 — breaking change to two rule_ids
If you match on rule_id, update these:
| 1.0 | 2.0 |
|---|---|
claude-hook-autorun |
agent-hook-autorun |
claude-hook-autorun-dangerous |
agent-hook-autorun-dangerous |
The auto-run hook detector is no longer Claude-specific — it recognises Cursor, Windsurf, Cline and Roo hook configurations too, so a vendor-named id had become misleading. Nothing else in the schema changed.
Verifying it works
The repo ships a deliberately-vulnerable sample and a clean baseline:
python references/test-corpus/_generate_corpus.py # materialize fixtures
python scripts/praetor.py references/test-corpus/vulnerable # expect many findings
python scripts/praetor.py references/test-corpus/clean # expect ~none (from code engines)
All "secrets" in the corpus are fake, generated from harmless parts - see
references/test-corpus/README.md.
On false positives, and what gets suppressed
Scanning code that legitimately contains security patterns — a scanner's own rules, security documentation, example payloads — will match on those strings. PRAETOR reduces that noise by asking whether the matched text can actually do anything:
| Pass | Suppresses when… |
|---|---|
| inline ignore | the flagged line carries praetor:ignore / nosec / nosemgrep |
| lexical context | the match is inside a comment or docstring — text that cannot execute |
| reachability | the matched string provably never reaches a dangerous sink (exec, shell, filesystem, network). Python, intra-file |
| heuristics | example/template env files, integrity hashes in lockfiles, low-confidence phrasing in docs |
Comment syntax is selected from the flagged file's type, never inferred from a
line alone: a Markdown heading and // within a YAML URL are content, not an
authorization to suppress. Unknown file types fail safe to code and keep the
finding.
Nothing is deleted. Suppressed findings move to the FILTERED bucket carrying the reason, so you can audit every suppression rather than trust it.
Two properties worth knowing before you rely on this:
🔴 Secrets are never suppressed by context or reachability. A dangerous command in a comment is inert — a comment cannot execute. A credential in a comment is still leaked, because a secret is disclosed by being written down, not by being executed. Reachability does not change that: a key declared in one module and used in another never reaches a sink in the file that declares it. Both passes therefore apply to the AI-security engine only, and that carve-out is enforced by tests that call the real suppression functions — not by a convention, and not by a test that merely inspects the config those functions read.
⚠️ Scope of that promise, stated exactly. It covers the two passes above. Two
older heuristics can still move a secret to FILTERED, in narrow cases: a secret in
an .env.example/.env.template-style file, and a low-confidence, low-entropy value
assigned to a secret-named variable. Both are visible in the FILTERED bucket with a
reason. So the accurate claim is "context and reachability never suppress a secret",
not "nothing ever does" — read the FILTERED bucket, do not assume it is all noise.
🟢 Suppression fails safe. Anything PRAETOR cannot prove inert is kept — unparseable source, an unfamiliar construct, a non-Python file, a value that escapes single-file analysis. A classifier that failed toward suppression would be a scanner that goes quiet under exactly the conditions an attacker creates.
PRAETOR also does not self-exempt: there is no rule excusing files "because
they look like detection rules", and none excusing tests/ — such a rule would
also excuse a real credential committed in a test file, which is a common real
leak. What remains after suppression is a short list a human can actually read.
Honest limits
PRAETOR is a high-signal aid, not a guarantee of security. It is static
(no runtime/logic/authorization flaws), its coverage equals its rules and
advisory databases (never exhaustive), and its pattern-based AI-security engine
raises the cost of an attack rather than closing it. Treat every finding as a
lead to verify and every clean result as "nothing matched these rules." Full
detail in references/LIMITS.md.
Architecture
See references/ARCHITECTURE.md for how each engine
and the interpretation layer (dedup, ranking, FP filtering) work, and the design
decisions behind them.
There is a second implementation, in progress
Python is the reference implementation and is what you get from a clone or
pip install .. A Rust workspace also lives under rust/. The
secrets detector has a scan() entry point in praetor-core and is held to
the Python implementation by the differential runner; shared text handling,
SCA argv construction, and generated Unicode tables also exist there. No Rust
engine is wired into the CLI yet, so the binary refuses to scan rather than
pretend to.
If you are contributing, two things bind you before you touch either tree:
- Acceptance is differential — both implementations must produce identical
(engine, rule_id, file, line)sets over one shared corpus. - 🔴
references/differential/*.expectedis a contract, not a fixture. Never regenerate it to make a test pass; a regenerated expectation agrees with whatever produced it, which is exactly the failure it exists to catch.
Rationale, conditions and the counter-argument that lost:
references/ADR-001-engine-language.md.
License
GNU Affero General Public License, version 3 - see LICENSE.
PRAETOR was MIT-licensed until 2026-09-04. What that means for you:
- Using PRAETOR to scan your code changes nothing. Running a scanner over your repository, in a terminal or in CI, does not make your repository a derivative work. Scan whatever you like, including proprietary code.
- Modifying PRAETOR and running it as a network service does trigger the AGPL's section 13: your users must be able to get your modified source.
- A licence change is not retroactive. Every commit published before the
change stays available under MIT, and anyone who obtained a copy under those
terms keeps them permanently. The MIT text and the last MIT commit are
recorded in
LICENSE-MIT-HISTORICAL.txtso that is checkable rather than remembered. - ⚠️ Some organisations prohibit AGPL software internally. That is a real cost of this choice, stated here rather than discovered later.
The frontier capabilities are in the open-source core, not held back. Attack-chain correlation, the agent capability profile and serialized-model scanning all ship here, under this licence, with no cap and nothing phoning home. They are the reason to choose PRAETOR, and they cannot be that if you cannot see them work.
Metadata
Release files for praetor-security 1.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| praetor_security-1.1.0.tar.gz | 419.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| praetor_security-1.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 643.2 kB
Release files / praetor_security-1.1.0.tar.gz
| Download URL | praetor_security-1.1.0.tar.gz |
|---|---|
| Size | 419.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ee63a3c2bb3ccd7b8c3988c574c10d2e666bd64fe936cf43dc961641ee42d96a
|
|
BLAKE2b-256 checksum How to use checksums |
e6ecdc6f2b18be0c966086417c71b3f63750436d69c5c63c0995e4f50282caa7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency logRelease files / praetor_security-1.1.0-py3-none-any.whl
| Download URL | praetor_security-1.1.0-py3-none-any.whl |
|---|---|
| Size | 224.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f75782d0a53d01d2048588c23e627e87b4bd3abb803a05f7f6ba22363dbe151c
|
|
BLAKE2b-256 checksum How to use checksums |
b69adebdb6dd8331555eff0ee000e43e08cca90c3356fdff177fad39682c6e91
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency log