Skip to content

Pitangus 0.14.0

Choose a tag to compare

@BrayansStivens BrayansStivens released this 15 Oct 16:18
· 39 commits to main since this release
4581151

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 (admin123 does); 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 with PITANGUS_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-password is 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
    (committed node_modules/ and vendor/, 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:allow comments are
    honoured and declared (excluded with file and line, never fixed; PITANGUS_SECRETS_IGNORE_INLINE_ALLOW=1 ignores
    them). The secret pass is slower with committed dependency folders (about 35 s more for a 600 MB node_modules/).
  • Rules for code written with AI. Supabase service_role keys, secrets in public-prefixed variables
    (VITE_*_API_KEY), CORS with credentials, routes without authentication (a global express.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, /.env and /.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 run with the pitangus-cache volume; 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-sink is 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_secrets and mask_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) and render_stored_texts_lexicon (the same again with the
    renderer that keeps only common words, never a word for its position) and web_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 by gitleaks: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: take make backup before upgrading (make update already does); it's the only way back.
  • The exposure check runs even with PITANGUS_DAST_ENABLED off: 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 / .betterleaksignore is 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=1 makes them allow nothing.

Known limitations

  • appsec.js.route-without-auth: false positives with routers authenticated in another file, Fastify's
    addHook('onRequest'), FastAPI's APIRouter(dependencies=[…]); misses app.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 over auth.users; reports RLS enabled inside DO $$ … $$.

  • appsec.js.public-env-secret: misses *_KEY without API_, process.env["…"], destructuring.

  • Web: an intermittent exposure is dropped; one connection error from testssl.sh or nuclei leaves the host incomplete;
    nginx-eol is 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.toml that 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.