A local static security scanner for source trees.
The half of the engagement that happens before the report.
Alpha. The rule ids and the config schema may still change between minor versions. The output contract that other tools integrate against is versioned separately and reported by
lory-scan describe.
lory-scan reads your source and reports the security problems it can see:
injection sinks, hardcoded credentials, weak cryptography, unsafe
deserialisation, and dangerous configuration — across Python, JavaScript,
TypeScript, PHP, Java, Kotlin, Go, C#, Ruby, shell, Dockerfiles, Kubernetes
manifests, Terraform, and CI workflows.
Nothing leaves your machine. There is no account, no API key, and no network call in a scan. The rules are files in this package and they run against files on your disk.
It is the scanning half of the Lorikeet terminal toolchain. Its companion,
lory-code-security,
is a findings cockpit that triages findings and asks Lory how to fix them
but scans nothing itself. This tool scans, and hands its findings straight to
that one:
lory-scan sync # scan this repo
lory tui --cached # triage what it found- Overview
- What it finds, and what it does not
- Install
- Quick start
- Setting a repository up
- Working with the findings TUI
- Command reference
- Output formats
- Suppressing a finding
- Baselines: adopting the scanner on an existing codebase
- Rules
- CI
- Configuration
- Architecture
- Performance
- What leaves your machine
- Development
- Roadmap
- Legal & authorized use
- License
Most security findings reach a developer long after the code was written, in a
PDF, described in terms of a URL rather than a file. lory-scan runs on the
tree in front of you and reports in the only vocabulary that helps —
path:line, the line itself, and what to do about it.
Every finding carries:
| Where | file, line, column, and the source line |
| What | a title, a description of why it is dangerous, and a concrete fix |
| How bad | one of five severities |
| How sure | a confidence, because a static match is a lead and not a proof |
| Which class | a CWE id, and an OWASP Top 10 category where one applies |
| Identity | a fingerprint that survives edits elsewhere in the file |
That last one is what makes the tool usable across runs. A finding is identified by a hash of the rule, the file, and the matched text — not by its line number — so adding an import above it does not turn it into a new finding, and your triage notes stay attached to the thing you made them about.
It finds patterns with metadata attached — roughly 100 rules, each with a CWE, a severity, a confidence, and a remediation:
- Secrets — AWS, GitHub, GitLab, Slack, Stripe, Google, Twilio, SendGrid,
npm, and model-provider keys; private key blocks; connection strings with
inline passwords; committed
.envfiles; plus generic high-entropy credential detection for everything with no recognisable shape. - Injection — SQL built by concatenation or interpolation, shell commands
assembled from variables,
evalon dynamic expressions, NoSQL operator injection, PHP file inclusion. - Deserialisation —
pickle, unsafeyaml.load, PHPunserialize, JavareadObject, .NETBinaryFormatter, RubyMarshal. - Cryptography — disabled TLS verification, trust-everything certificate handlers, MD5 and SHA-1 where they matter, ECB mode, non-cryptographic randomness used for tokens.
- Web — XSS sinks, CORS wildcards paired with credentials, JWT verification switched off, open redirects, missing cookie flags.
- Configuration — debug mode enabled, privileged containers, public
buckets and security groups, unencrypted storage,
pull_request_targetworkflows that check out untrusted code with your secrets.
It does not build a call graph, track taint across functions, or prove that
a sink is reachable. That is a deliberate boundary, not an oversight: a fast,
honest lead generator that a human confirms in ten seconds beats a slow one
that claims certainty it cannot have. The confidence field is where that
uncertainty lives, and --min-confidence medium is the fastest way to quieten
a first scan.
It is also not a dependency scanner. Vulnerable-package detection needs a vulnerability database, which means network access and a feed to keep current — a different tool with different guarantees.
# Recommended: an isolated install, on your PATH.
pipx install lory-code-security-scanner
# Or into the current environment.
pip install lory-code-security-scannerFrom a checkout:
git clone https://github.com/Lorikeet-Security/lory-code-security-scanner
cd lory-code-security-scanner
pip install -e ".[dev]"Python 3.11 or newer. Three dependencies — click, pyyaml, rich — the same
three the findings TUI uses, so installing both pulls in nothing new.
# Scan the current directory.
lory-scan scan
# Scan one subtree, and only show what matters today.
lory-scan scan src --min-severity high
# Scan only what your branch changed — the pre-commit and PR shape.
lory-scan scan --diff origin/main
# Machine-readable, for a pipeline.
lory-scan scan --format json --out findings.json
lory-scan scan --format sarif --out findings.sarif
# What would it even look for?
lory-scan rules list --category secrets
lory-scan rules show python.pickle-loadsExit codes, which are the whole interface in CI:
| Code | Meaning |
|---|---|
0 |
no findings at or above --fail-on (default: high) |
1 |
findings at or above --fail-on |
2 |
the scan could not run — bad config, bad rules, unreadable path |
130 |
interrupted (Ctrl-C) — kept distinct from 1 so an abandoned scan is not read as a failing one |
One scan tells you where you stand. init is what makes it stick:
lory-scan init # asks about each piece
lory-scan init --yes # takes the defaultsIt writes four things, any of which you can decline:
.lory-scan.yml |
a starting policy, with the noisy dials pre-set |
.lory-scan-baseline.json |
everything that exists today, marked known |
.git/hooks/pre-commit |
scans staged files before they are committed |
.github/workflows/security-scan.yml |
scans the diff on every PR, uploads SARIF |
The baseline is the part that matters for adoption. A first scan of a mature repository finds a lot of code that predates everyone reading the output; left in the way, the whole thing gets ignored. Baselining draws a line under it and holds new code to the standard, and because fingerprints ignore line numbers, the file does not go stale when someone reformats.
Nothing is overwritten without asking, everything it writes is a plain file you can edit, and running it again is safe. Commit the config and the baseline so the whole team scans the same way.
lory-code-security
is a full-screen cockpit for triaging findings: severity-ordered list, full
detail pane, local triage state, and a chat with Lory about how to fix the one
you are looking at. It reads findings from the Lorikeet platform over MCP — and
it can read this scanner's findings the same way, with no credential and no
network.
pip install "lory-code-security[tui,scan]"
cd ~/src/myapp
lory scan # the cockpit drives this scanner
lory tui --cached # triage what it found — or press `s` in the cockpitThat is the same scan, run from the other side: lory scan calls
lory-scan describe, checks the contract version, and asks for findings in its
own row format. Driving it from here instead works identically and needs
nothing installed but this package:
lory-scan sync # scan, and write .lory_state/findings.json
lory tui --cachedsync writes the findings cache that tool already reads, so its whole workflow
works on locally scanned findings:
lory findings list --cached # severity-ordered table
lory findings show scan-a1b2c3d4e5 # full detail
lory triage scan-a1b2c3d4e5 fixing # local workflow state
lory fix scan-a1b2c3d4e5 # ask Lory for the code changeThree properties make that handoff safe:
- Platform findings survive.
synckeeps rows from other finding stores that are already in the cache, so scanning locally never makes the findings from your last pentest disappear.--replaceopts out. - Refs never collide. Scanner findings are namespaced into their own store
and named
scan-<fingerprint>, which cannot clash with a platform ref. - Findings are complete. Each row carries its description, evidence, and remediation, so the TUI never needs to call the platform to fill one in.
Findings from your Lorikeet account and findings from a local scan sit side by side in that cockpit: a scan replaces the previous scan, and never touches findings from another store.
Prefer to pipe it yourself, or integrating something else? The contract is machine-readable:
lory-scan describe # the whole contract, as JSON
lory-scan scan . --format lory-json --quiet # the rows, on stdoutdescribe reports a contract_version alongside the tool version, so a caller
can refuse a contract it does not understand instead of misreading the output.
Scan a path and report findings. Defaults to the current directory. PATH may also be a single file.
What to scan
| Flag | Effect |
|---|---|
--include GLOB |
Only scan paths matching this glob. Repeatable. |
--exclude GLOB |
Skip paths matching this glob. Repeatable. |
--diff REF |
Only scan files changed against this git ref. |
--staged |
Only scan files staged in git. |
--no-gitignore |
Scan files git ignores. |
--max-file-size BYTES |
Skip files larger than this. Default 2 MB. |
Which rules run
| Flag | Effect |
|---|---|
--select GLOB |
Only rules matching this id glob. Repeatable. |
--ignore-rule GLOB |
Skip rules matching this id glob. Wins over --select. |
--category NAME |
Only rules in this category. Repeatable. |
--rules PATH |
Extra rule file or directory. Repeatable. |
--no-default-rules |
Use only --rules, not the bundled set. |
--no-entropy |
Skip generic high-entropy secret detection. |
What gets reported
| Flag | Effect |
|---|---|
--min-severity LEVEL |
Drop findings below this severity. |
--min-confidence LEVEL |
Drop findings below this confidence. |
--fail-on LEVEL |
Exit 1 at this severity. never always exits 0. |
--max-findings N |
Stop after N findings. |
--baseline FILE |
Suppress findings whose fingerprint is in the baseline. |
--write-baseline FILE |
Record this run's findings as known, and report nothing. |
--no-suppressions |
Do not honour lory-scan:ignore comments. |
--ignore-suppressions |
Report suppressed findings anyway, marked as suppressed. |
Where it goes
| Flag | Effect |
|---|---|
-f, --format |
table, json, sarif, lory-json, lory-cache, csv, markdown. |
-o, --out FILE |
Write to a file instead of stdout. |
--emit-lory-cache |
Also write the findings cache the TUI reads. |
--no-snippets |
Omit source lines from the table view. |
-q, --quiet |
Findings only; no summary. |
-j, --jobs N |
Worker processes. 0 picks from the machine's cores. |
Set a repository up: config, baseline, pre-commit hook, CI workflow. Takes
--yes to accept the defaults, --no-hook / --no-ci / --no-baseline to
decline a piece, --fail-on and --min-confidence to set the policy it
writes, and --force to replace files that already exist.
Scan, and write the findings cache that lory reads. Takes every scan
option, plus --state-dir and --replace.
lory-scan rules list # everything, severity-ordered
lory-scan rules list --language php # what applies to one language
lory-scan rules list --json # for a script
lory-scan rules show php.file-inclusion # one rule, patterns included
lory-scan rules validate # compile every rule and run its self-testsPrint the machine-readable integration contract: how to invoke a scan, what the exit codes mean, which fields a finding carries, and where the TUI's cache lives.
| Format | For |
|---|---|
table |
Reading. One panel per finding: location, rule, source line, why, fix. |
json |
Scripting. Findings plus the stats that explain the run. |
sarif |
GitHub code scanning, Defender, and anything else that ingests SARIF. |
lory-json |
The findings TUI's own row shape, on stdout. |
lory-cache |
The same, written to .lory_state/findings.json. |
csv |
Spreadsheets and ticket imports. |
markdown |
Pasting into a pull request or a ticket. |
Findings go to stdout; progress, warnings, and errors go to stderr. A
machine-readable format writes nothing but the document to stdout, so you never
need --quiet to parse it.
The SARIF output carries partialFingerprints, so GitHub tracks an alert
across commits that move the code instead of closing it and opening a new one.
A scanner without a way to say "I looked at this, it is fine" gets its output ignored wholesale, which is worse than a false negative. Record the decision next to the code it excuses:
# lory-scan:ignore[python.subprocess-shell-true] -- argv is a literal, built above
subprocess.run(f"systemctl restart {SERVICE}", shell=True)The comment works at the end of the offending line or on the line directly above it. Both spellings are accepted:
query = build(raw) # lory-scan:ignore
query = build(raw) # lory-scan:ignore[python.sql-string-building] -- parameterised belowPrefer the bracketed form. A bare lory-scan:ignore also hides the next bug
someone introduces on that line.
A whole file can be excused with lory-scan:ignore-file, anywhere in it:
# lory-scan:ignore-file[secrets.*] -- sample credentials, inventedThat is for files whose content is the subject: a rule file full of
credential patterns, a fixture that is wrong on purpose, documentation showing
what a leaked key looks like. Rule ids accept globs in both forms, so a file
can excuse one family without going blind to everything else. This tool's own
rules/secrets.yml carries exactly that line.
To see everything that has been waved through:
lory-scan scan --ignore-suppressionsA first scan of a mature repository finds a lot. A baseline lets you draw a line under it and hold new code to the standard, without stopping the world:
lory-scan scan --write-baseline .lory-scan-baseline.json
git add .lory-scan-baseline.json && git commit -m "Baseline lory-scan"
# From now on, only what is new:
lory-scan scan --baseline .lory-scan-baseline.jsonThe file records fingerprints alongside the rule, path, and title of each finding, so it is reviewable by eye — a bare list of hashes is a place bugs go to be forgotten. Because fingerprints ignore line numbers, a baseline does not go stale every time someone reformats a file.
A baseline is not a fix. Everything in it is still there.
A rule is a regex with enough metadata around it to be actionable. They live in YAML so a team can add their own without touching Python, and so the bundled set is reviewable as data.
lory-scan rules show python.yaml-unsafe-load- id: python.yaml-unsafe-load
title: YAML parsed with an unsafe loader
severity: critical
confidence: high
category: deserialization
cwe: 502
languages: [python]
patterns:
- 'yaml\.load\s*\((?![^)]*Loader\s*=\s*(?:yaml\.)?(?:Safe|Base|C?Safe)Loader)'
- 'yaml\.unsafe_load\s*\('
description: >
yaml.load with the default loader instantiates arbitrary Python objects via
tags such as !!python/object/apply, which makes a YAML document a
code-execution vector.
remediation: >
Use yaml.safe_load(), or pass Loader=yaml.SafeLoader explicitly.
examples:
- 'yaml.load(request.data)'
counterexamples:
- 'yaml.safe_load(request.data)'
- 'yaml.load(data, Loader=yaml.SafeLoader)'Point --rules at a file or directory, or set rule_dirs in the config. A
local rule that reuses a bundled rule's id replaces it, which is how you
retune a noisy rule without forking the package.
| Field | Meaning |
|---|---|
id |
Lowercase words joined by . or -. Required. |
title, severity |
Required. Severity is one of the five. |
pattern / patterns |
Regex, or a list of alternatives. At least one required. |
not_pattern / not_patterns |
Vetoes tested against the matched line. |
requires |
The file must also contain this, or the rule does not run. |
not_requires |
The rule does not run if the file contains this — for "missing mitigation" rules, where the mitigation lives elsewhere in the file. |
languages |
Which languages it applies to. Omit for all. |
paths / exclude_paths |
Path globs the rule is scoped to. |
confidence |
high, medium, or low. Default medium. |
cwe, owasp, category, tags |
Classification. |
description, remediation, references |
What a reader acts on. |
prefilter / prefilters |
Literals at least one of which every match must contain. See below. |
examples / counterexamples |
Strings the rule must and must not flag. |
prefilters is the one field that can silently break a rule. It is a promise
that every possible match contains one of those literals; the engine uses it to
skip a file without running any regex, which is most of the tool's speed. Get
it wrong and the rule stops firing — with no error, ever.
That is what examples are for:
lory-scan rules validateOK 100 rules loaded and compiled
15 categories · 20 languages · 44/100 with a literal prefilter
103 self-tests passed across 49 rules
Every example is run through the whole pipeline — prefilter, requires,
vetoes, patterns — so a bad prefilter fails here rather than in silence. CI
runs this on every push, and any rule you write should carry at least one
example and one counterexample.
Examples of secrets are written as shapes, not values. A rule that detects Stripe keys needs an example shaped exactly like a Stripe key — and committing one gets the repository rejected by GitHub's push protection, which is a warning nobody should be trained to click through. So examples carry placeholders, filled in when the rule loads:
examples:
- 'sk_live_{a:24}' # 24 alphanumerics
- 'twilio_sid = "AC{h:32}"' # 32 hex characters
- 'xoxb-{d:12}-{d:13}-{a:24}' # digit groups, then 24 alphanumerics| Placeholder | Fills with |
|---|---|
{a:N} |
N letters and digits |
{u:N} |
N uppercase letters and digits |
{h:N} |
N lowercase hex characters |
{d:N} |
N digits |
The expansion is deterministic, so a failing self-test is reproducible rather than a flake, and no realistic credential ever exists in this repository.
Prefilters are derived automatically when a pattern has a mandatory literal. The derivation is deliberately timid: anything with an alternation gets no prefilter, because a literal from one branch would make the rule blind to the others.
name: Security scan
on: [push, pull_request]
jobs:
lory-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # --diff needs history
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install lory-code-security-scanner
# Fail the build on new high-severity findings only.
- run: lory-scan scan --diff origin/${{ github.base_ref || 'main' }} --fail-on high
# And publish everything to the Security tab.
- run: lory-scan scan --format sarif --out lory-scan.sarif --fail-on never
if: always()
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: lory-scan.sarifAs a pre-commit hook:
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: lory-scan
name: lory-scan
entry: lory-scan scan --staged --fail-on high --quiet
language: system
pass_filenames: falseOptional. A repository with no config file scans with the bundled rules and
sensible excludes. See .lory-scan.example.yml for
every key, commented.
# .lory-scan.yml
exclude:
- "**/fixtures/**"
min_confidence: medium
fail_on: high
ignore_rules:
- js.express-no-helmet
severity_overrides:
docker.latest-tag: infoThe file is looked for at the scan root and upwards to the repository root, so
scanning a subdirectory still picks up the policy the team agreed on. Flags win
over the file; list-valued flags add to the file's lists rather than
replacing them, so a one-off --exclude cannot silently drop the repository's
agreed excludes.
An unknown key is an error, not a warning — a typo in a policy file must not be read as "no policy".
lory-scan scan .
│
├── config .lory-scan.yml, found at the root or above, then flags
│
├── rules bundled YAML + your own, compiled once
│
├── discovery git ls-files in a checkout, else a pruning walk
│ → excludes → language → size
│
├── matching per file: prefilter → requires → patterns → line vetoes
│ (in worker processes, largest files first)
│
├── filtering inline suppressions → baseline → severity and confidence
│
└── report table · json · sarif · lory-json · lory-cache · csv · markdown
src/lory_scanner/
├── cli/ command surface: scan, sync, rules, describe
├── core/ config, severities, error types
├── engine/ rules, walker, matcher, entropy, suppressions, languages
├── report/ Finding model, and one module per output family
└── rules/ the bundled ruleset, as YAML
The Finding model owns two serialisations: to_dict() is the native shape,
and to_lory_row() is the findings TUI's vocabulary. Keeping the translation
in one place is what makes the integration a contract rather than a convention.
The engine's speed comes from rejecting work cheaply, in this order:
- Discovery. In a git checkout the file list comes from
git ls-files, which knows what is ignored — no per-pathcheck-ignore, which on a large repository can take longer than the scan itself. - Compiled globs. Every exclude pattern is translated into one regex, so
filtering a candidate is a single match rather than one
fnmatchper pattern. - Prefilters. A rule whose mandatory literal is absent from the file is skipped without running a single regex. This is the largest win by far.
- Language gating. A PHP rule never touches a Python file.
- Parallelism. Files are scanned in worker processes, largest first, so the tail of a run is not one worker finishing a 600 KB file alone.
On a 4-core laptop, a 4,400-file / 55 MB PHP monorepo scans in about 25
seconds — down from nearly two minutes before the steps above; a typical
service repository is well under a second. -j 1 forces a
single process, which is what the tests use.
Nothing.
There is no network code in a scan — no telemetry, no rule updates, no
submission of findings anywhere. lory-scan describe reports this as part of
its contract so a caller can verify it rather than take it on trust.
Two consequences worth stating plainly:
- Reports inherit the secrecy of your code. A findings file quotes the
lines it found. Treat
findings.json,.sarif, and.lory_state/the way you treat the repository — the tool's own output is excluded from scans by default so that a report never becomes a source of new findings. - Credentials are redacted in output. A secret finding masks the value in
the matched text, the source line, and the context, keeping a short prefix
and the length so you can still find it:
NPM_TOKEN=npm_…[redacted, 40 chars]. The file and line are always shown in full.
pip install -e ".[dev]"
pytest -q # 137 tests
ruff check src tests
lory-scan rules validate # compile every rule and run its self-tests
# The scanner on itself. The fixtures are wrong on purpose, so scan src/.
lory-scan scan src --fail-on mediumexamples/vulnerable-app/ contains deliberately vulnerable fixtures — a Flask
app, an Express handler, legacy PHP, a Dockerfile, a .env, and Terraform.
They exist so the scanner has something to find. Do not deploy any of it, and
do not copy from it.
lory-scan scan examples/vulnerable-appPublishing is tag-driven, so nothing ships from a merge by accident:
# bump `version` in pyproject.toml, commit, then:
git tag v0.1.0 && git push origin v0.1.0The release workflow runs the tests, the linter, and the rule self-tests;
checks that the tag matches the version in pyproject.toml; builds the sdist
and wheel; installs the built wheel into a clean virtualenv and scans with it —
which is what catches a packaging mistake such as shipping without the rule
files — and only then publishes.
PyPI is configured with Trusted
Publishing, so there is no API
token stored in this repository to leak or rotate. One-time setup, on the PyPI
project's publishing settings: owner Lorikeet-Security, repository
lory-code-security-scanner, workflow release.yml, environment pypi.
- Dataflow within a single function, to raise confidence on the sink rules that currently guess.
- A tree-sitter backend for the languages where a parse beats a regex, keeping the YAML rule format for everything else.
- Rule packs per framework — Django, Rails, Laravel, Spring — installable separately.
lory-scan explain FINDING, handing a finding to Lory for a code-level fix without leaving the scanner.
Run this on code you own or are authorised to assess. It reads your working tree and writes reports where you tell it to; it changes no source and contacts nothing.
A clean scan is not a certificate. Static analysis sees patterns, not behaviour, and no scanner replaces a penetration test — which is the other half of what Lorikeet Security does.
MIT. See LICENSE.
