Repository navigation
Pitangus 0.14.0
In one paragraph
0.14 is about trusting what Pitangus tells you. A finding is marked fixed only when the engine that found it proved it
looked again and it was gone; changing the configuration, an engine that didn't finish or a site that rate-limited the
scan never closes anything. Identical problems are one finding with a count, the pull request gate counts copies the
same way, and folding findings never relaxes a triage decision. What Pitangus stores, prints or sends of your code is
its structure and its common words, never its values. Secrets move to Betterleaks with a filter that keeps real passwords and drops
placeholders; credential files, committed dependency folders and hidden files are scanned for secrets, and what an
engine still skips is declared. The exposure check is the always-available web check, and the developer quickstart runs
with one command and a cache that survives between runs.
On by default. PITANGUS_COVERAGE_MODE=on and PITANGUS_IDENTITY_MODE=v2 are the defaults in 0.14: every gate
below passed. On the first scan after upgrading the identity change opens and fixes nothing; set both to shadow if you
want to check with pitangus identity-gate first. GitHub code scanning will show existing SARIF alerts once as new,
because their partialFingerprints now carry the identity.
Highlights
- Nothing is fixed without proof. Per-file coverage for every engine, an engine contract (exit code, JSON, completion
mark) under fault injection on every adapter, web findings fixed only by their own tool's answer (401/403/429/5xx never
count; blocking judged across the whole run), and a fix hold when a scan would fix too much at once (30% and at least
10 findings by default, released only by a second covered scan). - Configuration never fixes. Each run records the engines and rules it ran with; turning a rule or an engine off,
updating a rule or a new secret filter leaves findings "Unverified (configuration)", and neither Jira nor the
notification channels hear about a held or unverified finding. - One identity per problem. A canonical identity from content (not lines), families with counts, conservative
triage folding (disagreeing or unreviewed copies send the family back to review; earliest expiry; every Jira issue
kept), and a PR gate that uses the registry's own identity (moving or reformatting is not new; pasting a copy is). - Structure and common words, never values. One renderer for every text that holds code, configuration or an
engine's words (snippets, SARIF snippets and context regions with their lines, an imported SARIF's rule texts, the
AI prompt's fragment, messages, steps, progress, limitations, engine errors, a failed scan's log line), whichever
engine or import produced it. It doesn't look for credentials and never keeps a word for its position: a word stays
only if it is a common word (language keywords, Pitangus's vocabulary in English and Spanish, common terms of code
and security) or splits entirely, at_, case changes and digits, into such words and numbers of up to three
digits; any other word is***wherever it is (key, identifier, operand, argument, comment, string, a URL's host or
path segment). Punctuation stays, so code reads. A URL in a text also loses its userinfo and query and fragment
values. zizmor findings keep only their route in the workflow. Identities are computed from the original first.
Secrets and hard-coded credentials (CWE-798/259/321/522) keep no snippet. The precise claim: a value survives only
if it is made entirely of common words and numbers of up to three digits (admin123does); no run of four or more
letters and digits of any other value appears in the database, prompt, SARIF, reports, Jira, notifications or logs
(a property test with a fragment oracle over every format and engine, also nightly). File paths are structure and
keep their names; URLs have no exception: a web finding's location, a run's target, the pages a scan reached and an
imported SARIF's references are rendered like any URL. A web page's identity and coverage read a key pinned before
rendering (scheme, host, port and digests of its path pieces and query), so identities stay across scans and two
pages shown alike stay two. Stored data is migrated (render_stored_texts,render_stored_texts_lexicon,
web_location_keys) without changing any identity. - A secret is its value, and two values are never one secret. One keyed HMAC identity per value whatever key,
format, quoting, engine or SARIF import found it: Betterleaks and Trivy reports of the same value are one finding.
Only names computed from the value link it to an earlier entry, never the engine's fingerprint: changing a value in
place fixes the old one and opens the new one, and a PR that does it introduces it. A master key rotation keeps
entries withPITANGUS_PREVIOUS_MASTER_KEY(without it they stay open, unverified, never fixed); a formula change is
versioned. 0.13's secrets, stored without their value's identity, are adopted once by the first scan that reads the
value. - Coverage by unit, not by path. Each finding declares the units it lives in; a dependency's is its project (the
manifest with its lockfile). A manifest that isn't empty without its lockfile, or a lockfile the engine listed no
package from, leaves the project unexamined, whatever it declares and in whatever syntax: deleting or emptying any
lockfile (Cargo, Pipfile, Poetry, Composer, Gradle…) never fixes its advisories. - Secrets. Betterleaks 1.9.0 replaces Gitleaks (463 built-in rules vs 222).
generic-passwordis on behind a
measured filter (18 public repositories: 1,525 matches → 23, keeping all 12 an independent review labeled real; of
the 23 kept, 12 are real and 18 real or seed data); Supabase anon keys,
placeholders and unsigned JWTs are set aside and counted, never silently dropped. Credential files (.env*,.npmrc,
SSH keys…) are scanned in a copy only the secret engines see. Betterleaks' and Trivy's own skip lists are off
(committednode_modules/andvendor/, lockfiles, minified and hidden files are read); each engine's log says
what it read, and a committed file it didn't read is listed as not analyzed by it.gitleaks:allowcomments are
honoured and declared (excluded with file and line, never fixed;PITANGUS_SECRETS_IGNORE_INLINE_ALLOW=1ignores
them). The secret pass is slower with committed dependency folders (about 35 s more for a 600 MBnode_modules/). - Rules for code written with AI. Supabase
service_rolekeys, secrets in public-prefixed variables
(VITE_*_API_KEY), CORS with credentials, routes without authentication (a globalexpress.json()no longer counts
as auth), JWTs decoded without verification, and a policy reader for Firebase rules and Supabase RLS per project and
schema. Third-party code copied into a repository is set apart (excluded with the reason). - Exposure check. httpx, testssl.sh and nuclei on each host's root and known paths, no crawling; HTTP on port 80
(each URL over its own scheme), soft-404 detection that survives pages echoing the path,/.envand/.git/config.
Web evidence is a whitelist: 2xx present, 404/410 absent, redirects prove nothing, everything else is a refusal. The full web scan (ZAP, katana) stays experimental and off. - Developer experience. One
docker runwith thepitangus-cachevolume; a pre-commit hook (pre-push) that scans
the pushed commit, never the working tree; the GitHub Action caches engine databases for the day. Measured on a small
repository with pre-commit: about 35 s the first time, 20 s after.
Gates for this release (section D)
| Gate | Result |
|---|---|
| Unit, property and invariant suites (I1–I10), with R1–R4 merged | green — 1,429 backend tests (R1–R4 together, F4/F5/F6 end to end), 227 panel tests, 66 rule tests |
| Property suites with the nightly profile (×30 examples), R1–R4 suites included | green — 28 suites; the random examples found three bugs, fixed with their seeds (a lockfile that is valid JSON but not an object stopped the scan; a URL password starting with digits and holding / passed as port and path; an AWS key id of letters only in a URL path was kept) |
| Fault injection on every engine adapter (web and non-web) | green |
| Data migrations on copies of the real 0.13 and 0.14-dev databases (test database only) | identities, statuses and stored-run comparisons unchanged; idempotent; same result in either order |
Pitangus' CI self-scan (--base origin/main, locally built image) |
PASS |
identity-gate --scan on the 5 real repositories (isolated, no Jira) |
0 / 0 on all five |
| Public corpus (17 repositories), before vs after | 3,061 → 3,053 findings, no true secret lost; the 5 real repositories unchanged (275 → 275) |
Identity gate, same commit scanned twice (before R1–R4; to be repeated):
| Repository | Findings (families) | Opened / fixed by fingerprint | Opened / fixed by identity |
|---|---|---|---|
| BrayansStivens/Proyecto-de-grado-Rizomas- | 190 | 0 / 0 | 0 / 0 |
| BrayansStivens/fastapi_templatle | 22 | 0 / 0 | 0 / 0 |
| BrayansStivens/sovereign-sdlc | 16 | 0 / 0 | 0 / 0 |
| Pitangus-Dev/pitangus | 76 | 0 / 0 | 0 / 0 |
| Pitangus-Dev/pitangus-dev.github.io | 0 | 0 / 0 | 0 / 0 |
Corpus, 0.13 line (fb-validacion) vs 0.14, default settings: 2,836 → 3,007 findings in the first round and 3,009 → 3,050 in the final one (all new findings from folders that used to be skipped). Where the difference comes from:
three repositories whose scan used to be incomplete because Trivy went blind on a Maven resolution now complete
(terragoat +259 infrastructure findings, spring-petclinic +37, WebGoat), generic-password adds 24 (on by default
now), the policy reader adds 4 true positives; curl-auth-header −70 and jwt −34 (placeholders, sample and anon
tokens set aside), dom-xss-sink −22 and eval-non-literal −4 (vendored libraries, now excluded with the reason),
route-without-auth −1 (NodeGoat's public root). Dependency advisories that "moved" are the same problem now owned by
Trivy with OSV as second engine, or merged by alias (a malicious version is one finding listing every advisory).
Found and fixed while running the gates: Betterleaks columns are bytes (secrets after non-ASCII text, and minified
bundles, were read at the wrong place: 23 false critical findings in Pitangus' own panel bundle); web findings of
different kinds of scan could share a family; the dashboard counted a renamed finding as fixed and new across the switch
to v2; Checkov could vouch for files it couldn't parse without naming them.
Also in the final round
- A manifest whose packages didn't all resolve, without its lockfile or in UTF-16 is unexamined for dependencies (a
Spring Boot project without network showed 7 advisories instead of 81 and looked complete). - Kinds of web scan keep their own findings; the PR gate counts moves and renames across the whole repository.
- Third-party code is recognized by folders and content, never by file name; everything committed goes through the
secret scanners whatever folder it's in;appsec.js.dom-xss-sinkis off by default (3 of 18 matches were XSS). - Malicious packages by the advisory's type, or two of three agreeing signals (CWE, title, description).
What's off, and why
- GitLab sast-rules (
PITANGUS_THIRD_PARTY_RULES): on an 18-repository corpus none reached 85% precision with at
least 3 hits. When turned on, a match on the same line and weakness as a Pitangus rule folds into it. generic-credential-uri: still too noisy; opt-in in Policies → Secret detection.- Checkov and Grype: optional (
PITANGUS_CHECKOV_ENABLED,PITANGUS_GRYPE_ENABLED); Trivy and zizmor cover the
default case. - Full web scan (ZAP, katana, subfinder): experimental,
PITANGUS_DAST_ENABLED.
Upgrading
Read Upgrading to 0.14 before the first scan. make update takes a backup,
pulls the engine images and migrates.
- Alembic:
0015_domain_session_check,0016_registry_control. - Data migrations, in order:
registry_identities(canonical identity on dependency findings),
registry_rule_baseline(stamps stored findings with the 0.13.1 rules so a rule changed in 0.14 never fixes on the
first scan),mask_stored_secretsandmask_stored_credentials(replace detected values and re-render affected
reports and SARIF),secret_value_identities(marks 0.14 secrets as named by their value),
registry_coverage_units(coverage units from each stored finding's path),render_stored_texts(every stored
text rendered as its structure; identities pinned first) andrender_stored_texts_lexicon(the same again with the
renderer that keeps only common words, never a word for its position) andweb_location_keys(every stored URL
rendered like any other, each web page's key and identity pinned first from the URL as stored). - Expect on the first scan: new secret findings (Betterleaks,
generic-password, and files the engines' own lists used
to skip: committed dependency folders, lockfiles, hidden and minified files), matches allowed bygitleaks:allow
listed under Excluded, more dependency projects "not examined" where a lockfile is missing or empty, some secrets set
aside as not secrets, findings in vendored files moved to Excluded, new policy-reader findings, malicious packages
merged; nothing opened or fixed by the identity change itself. 0.13's secrets are adopted once: a value changed
between the last 0.13 scan and the first 0.14 one takes the old entry's triage, so review accepted and
false-positive secrets. - The data migrations that mask and render stored texts rewrite runs, the registry and reports with no copy of the
original: takemake backupbefore upgrading (make updatealready does); it's the only way back. - The exposure check runs even with
PITANGUS_DAST_ENABLEDoff: admins only, verified domains only, anonymous, against
the pinned address it resolved to, but it actively probes ports 80/443 (httpx, testssl, nuclei). The full web scan
stays behind the flag. - A committed
.gitleaksignore/.betterleaksignoreis no longer loaded by Betterleaks itself (a commit could hide a
secret silently). Pitangus honours the root one's entries for the secret-only files and lists each allowed match under
Excluded with file and line, never fixed;PITANGUS_SECRETS_IGNORE_INLINE_ALLOW=1makes them allow nothing.
Known limitations
-
appsec.js.route-without-auth: false positives with routers authenticated in another file, Fastify's
addHook('onRequest'), FastAPI'sAPIRouter(dependencies=[…]); missesapp.use('/admin', auth); no Next.js or
NestJS coverage. -
Policy reader: misses writes guarded only by data validation,
if true || …,using (true)/with check (true),
views overauth.users; reports RLS enabled insideDO $$ … $$. -
appsec.js.public-env-secret: misses*_KEYwithoutAPI_,process.env["…"], destructuring. -
Web: an intermittent exposure is dropped; one connection error from testssl.sh or nuclei leaves the host incomplete;
nginx-eolis per page in the full scan; a 200 challenge on every path can stand in for the root's headers. -
Trivy 0.75 resolves a remote Maven parent silently partially on 401/403/404/5xx (only a 429 stops it); those
dependencies can be missing without a trace in Trivy's output. -
Web blocking thresholds (80% refusals, 5% 429s) are tuned on local fixtures, not yet on real CDNs.
-
Name-based heuristics for vendored code can set apart a first-party file named like a library (it stays visible
under Excluded with the reason). -
A
pyproject.tomlthat only configures tools and has no lockfile shows as not examined for dependencies: Pitangus
doesn't read declarations to tell, so it errs on the side of not examined.
Full list of changes: CHANGELOG.md.