* security: block IPv6 transition prefixes in SSRF defense
Adds four IPv6 transition / reserved prefixes to PRIVATE_IP_RANGES:
- 2002::/16 (6to4, RFC 3056 — wraps embedded IPv4 in 2002:xxxx:xxxx::)
- 64:ff9b::/96 (NAT64 well-known, RFC 6052 — maps RFC1918 to IPv6)
- 2001::/32 (Teredo, RFC 4380 — IPv6-over-UDP/IPv4 tunneling)
- 100::/64 (Discard prefix, RFC 6666 — sinkhole/test-only)
On Linux hosts with kernel sit0 / NAT64 routes configured, these
prefixes route to embedded private IPv4 addresses (e.g. 2002:7f00:1::
→ 127.0.0.1 via 6to4). Default Linux has no such routes so this is
not exploitable in the typical deployment, but blocking these closes
the gap for operators who do enable transition tunnels.
Tests:
- New TestIPv6TransitionPrefixesBlocked class with 5 parametrised
attack URLs (replaces the old TestOutOfScopeBehaviorLockedIn class
which documented the gap as deliberate).
- New membership tests in TestPrivateIPRanges for each prefix.
Documented in the deferred-items list of the original SSRF advisory
plan; this PR closes that residual concern.
* test(security): add anti-collision and allow-flag regression tests for IPv6 transition prefixes
Lock in the bit-level scoping of the four prefixes added in this PR
(2002::/16, 64:ff9b::/96, 2001::/32, 100::/64) so future widening or
refactors can't silently over-block legitimate global IPv6 allocations.
- Anti-collision tests confirm Google DNS (2001:4860::), Cloudflare DNS
(2606:4700::), root servers (2001:500::), HE TunnelBroker
(2001:470::), 2003::/16, RFC 8215 NAT64 local-use (64:ff9b:1::), and
100:1:: still pass validation.
- Positive-detection tests cover 6to4 and NAT64 wraps of every RFC1918
class plus AWS IMDS (169.254.169.254) — the high-value SSRF target —
under both is_ip_blocked and validate_url.
- Allow-flag matrix locks in that allow_localhost / allow_private_ips
do NOT bypass the new transition prefixes (the override only carves
out the local LOOPBACK_RANGES + PRIVATE_RANGES lists).
* security: close NAT64 RFC 8215 gap and add operator opt-in for IPv6-only deployments
Two follow-ups based on web research into the NAT64 SSRF threat surface
and the IPv6-only-deployment regression risk.
1) Block 64:ff9b:1::/48 (RFC 8215 NAT64 local-use prefix)
Same SSRF threat class as the well-known 64:ff9b::/96: on hosts
configured to route the local-use prefix, [64:ff9b:1::a9fe:a9fe]
reaches 169.254.169.254 identically to the WKP form. Missing this
prefix earned a HackerOne bounty against the Ruby ssrf_filter
library and is the exact gap behind several recent SSRF CVEs.
2) Operator opt-in via LDR_SECURITY_ALLOW_NAT64=true
IPv6-only cloud deployments using DNS64+NAT64 (AWS/GKE/Azure
IPv6-only) reach IPv4 services through 64:ff9b::/96. Defaulting to
block protects the typical LDR deployment shape (laptops, dual-
stack Docker) from the IPv6-wrapped IMDS/RFC1918 bypass class;
the env-only switch lets IPv6-only operators opt back in once they
have accepted the residual risk. Scoped strictly to the two NAT64
prefixes — 6to4, Teredo, and discard remain unconditionally
blocked because they have no live legitimate use in 2026.
3) Hardening: IMDS embedded-IPv4 check inside NAT64 wraps
ALWAYS_BLOCKED_METADATA_IPS is meant to be absolute. The NAT64
carve-out could otherwise re-open IMDS via [64:ff9b::a9fe:a9fe]
on misconfigured hosts. The validator now extracts the embedded
IPv4 from any NAT64-wrapped IPv6 destination and matches it
against the metadata block list before the NAT64 carve-out
applies, so the operator opt-in cannot license IMDS exposure.
Tests: 14 new cases covering the new prefix, env-var on/off matrix
(falsy values keep blocked, opt-in does not unblock 6to4/Teredo/
discard, opt-in does not unblock IMDS via WKP or local-use wrap, opt-in
does not unblock the IPv4 IMDS literal). Full security suite stays
green (3315 passed).
* security: extend NAT64-wrapped IMDS protection to notification validator + SECURITY.md
Parity follow-up so the two SSRF gates make the same "ALWAYS_BLOCKED"
promise about cloud-metadata IPs.
- notification_validator._ip_matches_blocked_range now does the same
embedded-IPv4 extraction as ssrf_validator.is_ip_blocked: when an
IPv6 destination falls in a NAT64 prefix, the low 32 bits are
matched against ALWAYS_BLOCKED_METADATA_IPS before the
security.allow_nat64 carve-out is consulted. Without this, an
operator running with both LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true
and LDR_SECURITY_ALLOW_NAT64=true could configure a webhook URL
like [64:ff9b::a9fe:a9fe] and reach IMDS via the notification path.
- 11 new tests under TestNat64EnvOptOutInNotificationValidator
covering: env unset blocks NAT64; env=true allows non-metadata
NAT64 wraps; env=true keeps IMDS / ECS / Alibaba metadata blocked
via WKP and RFC 8215 local-use wraps; env=true does NOT unblock
6to4/Teredo; the DNS-resolved branch of _is_private_ip also
enforces the embedded-IPv4 check.
- SECURITY.md "Cloud Metadata Endpoint Block List" gains a paragraph
documenting the IPv6-wrapped extraction. New "IPv6 Transition
Prefix Block List" section documents the five blocked prefixes
(including the new RFC 8215 entry) and the LDR_SECURITY_ALLOW_NAT64
opt-in for IPv6-only deployments.
* ci(security): drop IPv4 literal from security.allow_nat64 description
The whitelist-check CI gate flags any non-RFC1918/loopback IPv4 literal
outside test/example/.md files. The "169.254.169.254" example in the
new env-setting description tripped it. Rewrite to reference "cloud-
metadata" by name and point at SECURITY.md / ALWAYS_BLOCKED_METADATA_IPS
for the canonical list, which lives in ssrf_validator.py and is the
authoritative source. No behavior change.
* refactor(security): extract shared NAT64 metadata helper, dedupe test helper
Code-quality follow-ups from review:
- New is_nat64_wrapped_metadata_ip() helper in ssrf_validator.py
encapsulates the NAT64-prefix detection + embedded-IPv4 metadata
match. Both is_ip_blocked and NotificationURLValidator
._ip_matches_blocked_range now defer to it instead of carrying
parallel copies of the extraction. Keeps the two validators in
lockstep and removes the drift risk the reviewer flagged.
- Module-level _ip_is_private helper in test_ip_ranges.py replaces
the duplicate _is_private methods on TestIPv6TransitionPrefixes
AntiCollision and TestIPv6TransitionPrefixesPositiveDetection.
No behavior change. Full security suite: 3326 passed.
* security: close IPv4-mapped IPv6 IMDS gap in notification validator + lock-in tests
Audit follow-up — multi-agent regression review surfaced one real
cross-validator divergence and several test-coverage gaps worth
closing.
The bug
-------
ssrf_validator.is_ip_blocked unwraps IPv4-mapped IPv6 addresses
(::ffff:169.254.169.254 → 169.254.169.254) before consulting
ALWAYS_BLOCKED_METADATA_IPS, so the IMDS literal check fires.
notification_validator._ip_matches_blocked_range did not perform the
unwrap, so NotificationURLValidator._is_private_ip("::ffff:169.254.169.254")
returned False — IMDS reachable through the notification path with
the IPv4-mapped form. Pre-existing gap, but directly contradicts the
parity goal of commit 8708778f6. Mirrored the ssrf_validator unwrap
at the top of _ip_matches_blocked_range.
Tests added
-----------
- Notification validator: ::ffff:169.254.169.254, ::ffff:127.0.0.1
blocked; ::ffff:8.8.8.8 still passes (anti-collision).
- SSRF validator: env=true does NOT unblock IPv6 ULA (fc00::/7) or
link-local (fe80::/10) — pins the carve-out's scope so a refactor
of the loop's continue cannot widen it.
- New TestIsNat64WrappedMetadataIp unit tests for the shared helper:
IPv4 short-circuit, non-NAT64 IPv6, NAT64 wrap of non-metadata,
IMDS via WKP, IMDS via RFC 8215 local-use.
Full security suite: 3336 passed.
* security: block ::/96 (RFC 4291 IPv4-Compatible IPv6, deprecated)
Round-2 audit follow-up — IPv6 parsing edge-case agent surfaced that
[::169.254.169.254] (RFC 4291 IPv4-Compatible IPv6 form, deprecated
2006 but still parseable and routable on hosts with ::/96 routes
configured) reaches IMDS identically to the IPv4-mapped and
NAT64-wrapped forms. Confirmed by direct repro on the branch.
Same defense-in-depth class as the transition prefixes already
blocked in this PR (6to4 / Teredo / NAT64 / discard) — RFC-deprecated,
no live legitimate use, but exploitable on hosts with the relevant
kernel routes. Added to PRIVATE_IP_RANGES.
::/96 is a strict superset of ::1/128 (loopback) and ::/128
(unspecified), both already in the list. The carve-out logic in
is_ip_blocked checks LOOPBACK_RANGES separately, so ::1 with
allow_localhost=True remains unblocked — verified.
Tests: membership in PRIVATE_IP_RANGES, validate_url rejects
[::169.254.169.254] / [::a9fe:a9fe] / [::192.168.1.1]. Existing
public-IPv6 anti-collision tests (Google DNS, Cloudflare DNS, etc.)
still pass — those are 2000::/3 globals, far from ::/96.
SECURITY.md and changelog updated to list the new prefix.
Full security suite: 3340 passed.
* security: close metadata-IP bypass under allow_private_ips=True
Round-3 adversarial audit found a pre-existing bypass that directly
contradicts this PR's "ALWAYS_BLOCKED_METADATA_IPS is absolute" promise:
NotificationURLValidator.validate_service_url with allow_private_ips=True
short-circuited the entire host check (line 267: `if scheme in (http,
https) and not allow_private_ips:`). With the operator opt-in set,
http://169.254.169.254/, http://[::ffff:169.254.169.254]/, and
http://[64:ff9b::a9fe:a9fe]/ all returned valid=True — IMDS reachable
through the notification path, all of this PR's IPv6 hardening
bypassed.
ssrf_validator.is_ip_blocked correctly keeps IMDS blocked even with
allow_private_ips=True. The notification validator silently violated
that invariant. Operators set allow_private_ips=True for self-hosted
webhooks on internal networks (RFC1918, CGNAT, loopback) — not for
IMDS exfiltration.
Fix
---
- _ip_matches_blocked_range now delegates to ssrf_validator.is_ip_blocked
(single source of truth for ALWAYS_BLOCKED_METADATA_IPS, the NAT64
embedded-IPv4 hardening, the security.allow_nat64 env carve-out, and
the allow_private_ips operator flag).
- _is_private_ip accepts allow_private_ips and propagates it through.
The localhost-string shortcut no longer fires when the operator has
opted in (so the IP path / DNS path makes the decision and metadata
IPs are still caught).
- validate_service_url drops the `not allow_private_ips` guard at the
http/https check; the host check now runs unconditionally, with the
flag passed through.
Tests
-----
7 new TestNat64EnvOptOutInNotificationValidator cases cover the
historical bypass scenarios:
- IPv4 IMDS literal blocked under allow_private_ips=True
- IPv4-mapped IMDS blocked under allow_private_ips=True
- NAT64 WKP wrap of IMDS blocked under allow_private_ips=True
- NAT64 RFC 8215 local-use wrap of IMDS blocked under allow_private_ips=True
- AlibabaCloud metadata (100.100.100.200, ALSO in CGNAT range) blocked
- RFC1918 webhook still allowed under allow_private_ips=True (anti-collision)
- localhost still allowed under allow_private_ips=True (anti-collision)
SECURITY.md updated to document the absolute-block invariant under both
validators.
Full security suite: 3340 passed (3488 across security + web/services).
16 KiB
Security Policy
Reporting Security Vulnerabilities
We take security seriously in Local Deep Research. If you discover a security vulnerability, please follow these steps:
🔒 Private Disclosure
Please DO NOT open a public issue. Instead, report vulnerabilities privately through one of these methods:
-
GitHub Security Advisories (Preferred):
- Click the link above or go to Security tab → Report a vulnerability
- This creates a private discussion with maintainers
-
Email:
- Send details to the maintainers listed in CODEOWNERS
- Use "SECURITY:" prefix in subject line
What to Include
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes (optional)
Our Commitment
- We'll acknowledge receipt within 48 hours
- We'll provide an assessment within 1 week
- We'll work on a fix prioritizing based on severity
- We'll credit you in the fix (unless you prefer anonymity)
Vulnerability Disclosure Timeline
We follow a coordinated disclosure process with best-effort target timelines:
| Severity | Target Fix Time | Public Disclosure |
|---|---|---|
| Critical | 30 days | After fix released |
| High | 45 days | After fix released |
| Medium | 60 days | After fix released |
| Low | 90 days | After fix released |
Note: This is a community-maintained project. Actual fix times may vary depending on complexity and maintainer availability. We do our best to address security issues promptly.
- Coordination: We work with reporters to coordinate disclosure timing
- Credit: Reporters are credited in release notes and security advisories (unless anonymity requested)
- CVE Assignment: For significant vulnerabilities, we will request CVE assignment through GitHub Security Advisories
Security Considerations
This project processes user queries and search results. Key areas:
- No sensitive data in commits - We use strict whitelisting
- API key handling - Always use environment variables
- Search data - Queries are processed locally when possible
- Dependencies - Regularly updated via automated scanning
Database Encryption
Local Deep Research uses SQLCipher (AES-256-CBC) for database encryption. Each user's database is encrypted with their login password as the key, derived via PBKDF2-HMAC-SHA512 with 256,000 iterations and a per-user random salt. There is no separate password hash — authentication works by attempting to decrypt the database. API keys stored in the database are encrypted at rest.
In-Memory Credentials
Like all applications that use secrets at runtime — including password managers, browsers, and API clients — credentials are held in plain text in process memory during active sessions. This is an industry-wide reality acknowledged by OWASP, Microsoft (who deprecated SecureString for this reason), and the pyca/cryptography library.
Why in-process encryption does not help: If an attacker can read process memory, they can also read any decryption key stored in the same process. The password exists in Flask session storage, database connection managers, and thread-local storage throughout the application's lifetime — protecting only one copy (e.g., SQLCipher's internal buffers) does not meaningfully reduce exposure.
What we do to mitigate:
- Session-scoped credential lifetimes with automatic expiration
- Core dump exclusion via container security settings
Ideas for further improvements are always welcome via GitHub Issues.
Memory Security (cipher_memory_security)
SQLCipher's cipher_memory_security pragma controls whether SQLCipher zeroes its internal buffers after use and calls mlock() to prevent memory pages from being swapped to disk.
Default: OFF. Since the same password is unprotected elsewhere in process memory (see above), locking only SQLCipher's internal buffers does not meaningfully reduce exposure.
To enable memory security (e.g., for compliance requirements):
# Environment variable
LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY=ON
In Docker, mlock() requires the IPC_LOCK capability:
# docker-compose.yml
services:
local-deep-research:
cap_add:
- IPC_LOCK
environment:
- LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY=ON
Or with docker run:
docker run --cap-add IPC_LOCK -e LDR_DB_CONFIG_CIPHER_MEMORY_SECURITY=ON ...
IPC_LOCK is a narrow Linux capability that only permits memory locking — it does not grant any other privileges.
Notification Webhook SSRF
Outbound notifications via Apprise are disabled by default. To enable them, the operator must set LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true in the server environment. This is intentional: notifications carry a known residual SSRF risk that cannot be fully closed in code, and the env-only gate makes turning them on an explicit operator decision rather than something any logged-in user can flip via the settings API.
The residual risk
LDR validates user-configured notification service URLs (NotificationURLValidator) before handing them to Apprise. Hostnames are resolved once at validation time and the resulting IPs are checked against private/internal ranges. There is a known DNS rebinding TOCTOU window between this check and the actual outbound request:
- The window. Apprise (and its underlying
requests/urllib3stack) resolves the hostname again when it sends the notification. A DNS-rebinding attacker controlling a domain can serve a public IP to LDR's validator and a private IP to Apprise's send-time resolver — bypassing the private-IP check and reaching internal services on the LDR server (e.g.,127.0.0.1:<internal-port>) or the local network. This is exploitable by any logged-in user, not just by the deployment operator. - Why it isn't closed in code. Apprise exposes no Session/adapter/DNS hook. Closing the window would require monkey-patching
requestsinside Apprise's plugin namespace — fragile across Apprise versions, HTTPS-only, and doesn't handle redirects correctly. The blast radius outweighs the benefit.
How to enable notifications
LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true
By setting this, the operator acknowledges the residual risk above. To minimise it:
- Prefer plugin schemes over raw
http(s)://. Apprise plugin schemes (discord://,slack://,ntfy://,ntfys://,gotify://,telegram://,mattermost://,rocketchat://,teams://,matrix://,mailto://, etc.) hardcode their endpoints internally and have no user-controllable hostname — no SSRF surface. Use them whenever the target service supports them. - Restrict egress if private-network exposure is a concern: deploy LDR behind an egress-restricted network so that even a successful rebinding cannot reach internal services.
The same DNS-rebinding caveat applies to safe_requests / ssrf_validator.validate_url, used for general HTTP fetches (RAG sources, web scraping). Egress restriction is the primary defense for that path as well.
Parser-Differential URL Bypass (GHSA-g23j-2vwm-5c25)
A reporter (@Fushuling, @RacerZ-fighting) demonstrated that Python's urllib.parse.urlparse and the requests/urllib3 parser disagreed on URLs like http://127.0.0.1\@1.1.1.1 — urlparse extracted 1.1.1.1 (passing the SSRF check) while requests connected to 127.0.0.1 (the actual destination). The fix has two layers:
- Layer 1 — input hygiene:
RFC_FORBIDDEN_URL_CHARS_REinssrf_validator.pyrejects URLs containing backslash, ASCII control bytes, or whitespace. RFC 3986 forbids these characters in URLs, so legitimate fetches are unaffected. - Layer 2 — authoritative parser: Hostname extraction now uses
urllib3.util.parse_url, the same parserrequestsuses internally. Validator and HTTP client cannot disagree on destination by construction. This is the load-bearing defence on theSafeSession.sendpath, whererequestshas already canonicalised\to%5Cduring.prepare().
Both ssrf_validator.validate_url and NotificationURLValidator.validate_service_url (HTTP/HTTPS branch) carry the fix. Future edits to the SSRF path should preserve RFC_FORBIDDEN_URL_CHARS_RE and the urllib3.util.parse_url host extraction — reverting either reintroduces the bypass.
Cloud Metadata Endpoint Block List
ssrf_validator.ALWAYS_BLOCKED_METADATA_IPS is a frozenset of cloud-provider metadata IPs that are blocked under every flag combination, including allow_localhost=True and allow_private_ips=True. These IPs expose IAM / instance-role credentials and are never legitimate destinations for outbound HTTP. The current set is:
| IP | Provider |
|---|---|
169.254.169.254 |
AWS IMDSv1/v2, Azure, OCI, DigitalOcean (shared) |
169.254.170.2 |
AWS ECS task metadata v3 |
169.254.170.23 |
AWS ECS task metadata v4 |
169.254.0.23 |
Tencent Cloud |
100.100.100.200 |
AlibabaCloud |
The block also catches IPv6-wrapped forms of these metadata IPs. When an IPv6 destination falls in a NAT64 prefix (64:ff9b::/96 RFC 6052 well-known or 64:ff9b:1::/48 RFC 8215 local-use), the validator extracts the embedded IPv4 from the low 32 bits and matches it against this set — so [64:ff9b::a9fe:a9fe] cannot reach 169.254.169.254 even on a host with NAT64 routes configured. The check fires before any opt-in carve-out, so the operator switch described below cannot license IMDS exposure.
Both ssrf_validator.is_ip_blocked and NotificationURLValidator.validate_service_url enforce this absolutely, including under allow_private_ips=True. The latter flag is an operator opt-in for self-hosted webhooks on internal networks (RFC1918, CGNAT, loopback, link-local, IPv6 ULA); it does NOT extend to metadata IPs or NAT64-wrapped metadata. Both validators delegate to the same is_ip_blocked helper to keep the absolute-block invariant in lockstep.
Future contributors must not remove entries from this set. Adding a new cloud provider's metadata IP is encouraged when a new public-cloud target appears.
IPv6 Transition Prefix Block List
PRIVATE_IP_RANGES blocks four IPv6 prefixes that can wrap private-IPv4 destinations on hosts with kernel transition routes configured:
| Prefix | Purpose | RFC |
|---|---|---|
2002::/16 |
6to4 | RFC 3056 (deprecated by RFC 7526) |
64:ff9b::/96 |
NAT64 well-known prefix | RFC 6052 |
64:ff9b:1::/48 |
NAT64 local-use prefix | RFC 8215 |
2001::/32 |
Teredo | RFC 4380 |
100::/64 |
IPv6 discard prefix | RFC 6666 |
::/96 |
IPv4-Compatible IPv6 (deprecated) | RFC 4291 §2.5.5.1 |
Default Linux has no sit0 / NAT64 routes so this is defensive-only on the typical deployment, but blocking these prefixes closes the IPv6-wrapped SSRF bypass class on hosts where transition tunnels are enabled.
Operators on IPv6-only deployments using DNS64+NAT64 (AWS / GKE / Azure IPv6-only nodes) reach IPv4 services through 64:ff9b::/96. They can opt back into NAT64 reachability via the env-only setting security.allow_nat64 (LDR_SECURITY_ALLOW_NAT64=true). The opt-in is scoped strictly to the two NAT64 prefixes — 6to4, Teredo, and discard remain unconditionally blocked because they have no live legitimate use, and the IMDS embedded-IPv4 check above still applies so cloud metadata stays unreachable through any NAT64 wrap.
URL rejection log lines route through ssrf_validator.redact_url_for_log to drop userinfo (RFC 3986 §3.2.1 allows credentials in the URL), path, and query — operators see scheme://host:port only. Operators with grep/regex tooling on the rejection log lines will see authority-only strings instead of full URLs.
Supported Versions
Security fixes are only provided for the latest release. Please upgrade to receive patches.
Security Scanning & CI/CD
We maintain comprehensive automated security scanning across the entire development lifecycle:
Static Application Security Testing (SAST)
| Tool | Purpose | Frequency |
|---|---|---|
| CodeQL | Semantic code analysis for vulnerabilities | Every PR & push |
| Semgrep | Pattern-based security scanning | Every PR & push |
| Bandit | Python-specific security linting | Every PR & push |
| DevSkim | Security-focused linter | Every PR & push |
Dependency & Supply Chain Security
| Tool | Purpose | Frequency |
|---|---|---|
| OSV-Scanner | Open Source Vulnerability database | Every PR & push |
| npm audit | JavaScript dependency vulnerabilities | Every PR & push |
| RetireJS | Known vulnerable JS libraries | Every PR & push |
| SBOM Generation | Software Bill of Materials (Syft) | Weekly & releases |
| License Scanning | License compliance checking | Every PR |
Container Security
| Tool | Purpose | Frequency |
|---|---|---|
| Trivy | Container vulnerability scanning | Every PR & push |
| Hadolint | Dockerfile best practices | Every PR & push |
| Dockle | Container image security linting | Weekly |
| Image Pinning | Verify all images use SHA digests | Every PR |
Infrastructure & Configuration
| Tool | Purpose | Frequency |
|---|---|---|
| Checkov | Infrastructure-as-Code security | Every PR & push |
| Zizmor | GitHub Actions security | Every PR & push |
| OSSF Scorecard | Supply chain security metrics | Periodic |
Dynamic Application Security Testing (DAST)
| Tool | Purpose | Frequency |
|---|---|---|
| OWASP ZAP | Web application security scanning | Every PR & push |
| Security Headers | HTTP security header validation | Every PR & push |
Secrets Detection
| Tool | Purpose | Frequency |
|---|---|---|
| Gitleaks | Secret detection in commits | Every PR & push |
| File Whitelist | Prevent sensitive files in commits | Every PR & push |
Note: detect-secrets (Yelp) was removed in Feb 2026 because its line-number-based
.secrets.baselinefile caused constant merge conflicts across branches. Gitleaks provides equivalent pattern-based detection with path-based allowlists that are stable across line changes. CI also runs Semgrep (p/secrets) and Bearer (secrets) for additional coverage. Do not re-add detect-secrets.
Release Security
| Feature | Description |
|---|---|
| Cosign Signing | All Docker images are cryptographically signed |
| SLSA Provenance | Build attestations for supply chain verification |
| SBOM Attachments | SBOMs attached to container images and releases |
| Keyless Signing | Uses GitHub OIDC for Sigstore keyless signing |
Security Best Practices
All workflows follow security best practices:
- Pinned Actions: All GitHub Actions pinned to SHA hashes
- Minimal Permissions: Least-privilege permission model
- Runner Hardening: step-security/harden-runner on all workflows
- No Credential Persistence:
persist-credentials: falseon checkouts - Egress Auditing: Network egress monitoring enabled
OpenSSF Scorecard
We maintain a high OpenSSF Scorecard rating, measuring:
- Branch protection
- Dependency updates
- Security policy
- Signed releases
- CI/CD security
Thank you for helping keep Local Deep Research secure!