sota-code-security
Secure coding and security auditing rules (2026 baseline). Use whenever BUILDING or modifying code that crosses a trust boundary — endpoints, handlers, auth/login/signup, sessions, JWT/OAuth, file uploads, payments, multi-tenant features, crypto/secrets handling, parsers, CLI/exe
Install
npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-code-security
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
git clone https://github.com/martinholovsky/SOTA-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SOTA Code Security
Purpose
One skill, two modes. The rules/ files define the 2026 secure-coding baseline
(OWASP Top 10 2025/API 2023/LLM + Agentic Top 10, CWE-mapped). In BUILD mode
you write
code that conforms to the rules by default. In AUDIT mode you hunt for
violations of the same rules and report them as severity-rated findings. The
rules are the single source of truth for both — anything a rules file forbids
is a finding; anything it mandates is the implementation default.
Threat-model framing for both modes: every input is hostile until validated at a trust boundary; every output channel (response, error, log, model context) is adversary-readable; every privileged operation needs an explicit, code-enforced (never prompt-, comment-, or convention-enforced) authorization decision.
BUILD mode — secure-by-default while writing code
- Identify trust boundaries first. Before writing a handler/parser/job, name what crosses in (user input, third-party content, model output, file bytes) and what authority the code wields. Pick the relevant rules files from the index below and follow them as you write — not as a review pass.
- Defaults, not options. Use the rules' default choices without being
asked: parameterized queries, argv-exec, argon2id, AEAD via libsodium-class
libraries,
__Host-cookies, allowlist DTOs (extra=forbid), deny-by-default route policy, per-principal rate limits, timeouts on every outbound call. - Structural over disciplinary. Prefer designs where the insecure variant cannot be written: ownership predicates inside queries, RLS for tenancy, typed Secret wrappers with masked repr, central crypto/authz modules, logger redaction filters. If safety depends on every future dev remembering a rule, redesign.
- Never hand-roll crypto, session machinery, password hashing, JWT/OAuth protocol steps, HTML sanitizers, or auth token schemes. Compose vetted libraries per rules/02 and rules/04.
- When requirements force a deviation (e.g. shell-out unavoidable, CORS
must reflect origins), implement the rules file's documented mitigation
stack and leave a
SECURITY:comment stating the residual risk. - Every control must be falsifiable. For each control you add, ask: if
this were silently a no-op, would anything observable differ? If nothing
would — no log, no metric, no failing test — the control is not finished.
Assert on real loaded artifacts (not
exists()), fail closed and loudly, never truncate what you are about to inspect or parse, and make degradation a distinct, metered state. rules/10 is the full catalog. - Finish with the file's audit checklist. Before declaring code complete, run the relevant rules files' end-of-file checklists against your own diff; fix every "no".
AUDIT mode — hunting vulnerabilities against these rules
Process:
- Map the attack surface: entry points (routes, GraphQL resolvers, queue consumers, cron jobs, WS/gRPC, webhooks, file ingestion, LLM tool loops), secrets locations, authz enforcement points, outbound fetchers.
- Sweep by rules file, prioritized: 03 (authz) and 01 (injection) find the
most criticals; then 02, 05, 08, 04, 07, 06, and 10 (silent no-ops) as a
pass over whatever the others confirmed exists. For each file, grep-drive the
hunt from its named sinks/APIs (e.g.
shell=True,dangerouslySetInnerHTML,verify=False,pickle.loads,merge(,Object.assign(.*req.body,permit!,algorithms=absent nearjwt.). - Trace, don't pattern-match: confirm untrusted data actually reaches the sink and no upstream boundary neutralizes it. Report the full source→sink path. A reachable sink with attacker data = finding; an unreachable one = note as hardening debt, Low.
- Check the negatives: missing controls are findings too — absent rate limiting, absent CSRF tokens, absent tenant predicate, absent timeout, absent security headers. Use each rules file's audit checklist as the completeness gate; every "no" answer becomes a finding or an accepted risk.
- Check the inert: a control that is present but does nothing is invisible to steps 2–4, because the code is not wrong — it is a no-op. Run rules/10 as its own pass over every control the sweep confirmed exists: swallowed exceptions, weak existence checks, truncation into an inspector or out of a generator, degradation that never logs, and tests that pass against a no-op'd body. Sweep with rules/11 to decide where to look, then close with rules/15 on the tools that produced your findings — an unvalidated instrument has produced none.
- Verify, then report. No speculative findings: state the concrete exploit scenario; if exploitability is uncertain, say what's unverified and rate conservatively. Absence claims ("no instances of X") need a wider search and a second method than presence claims do.
Severity conventions (CVSS-style impact mapping)
| Severity | CVSS band | Criteria | Examples |
|---|---|---|---|
| Critical | 9.0–10.0 | Unauthenticated (or trivially authenticated) remote compromise of confidentiality/integrity at scale: RCE, SQLi dumping the DB, auth bypass, cross-tenant read/write, secrets in public repo/client bundle | pickle.loads(request.body); JWT alg not pinned; reflected-Origin CORS with credentials; tenant_id from request param |
| High | 7.0–8.9 | Single-user-scoped compromise or privileged-precondition full compromise: IDOR on sensitive objects, stored XSS, SSRF reaching metadata, authenticated command injection, session fixation, missing object-level authz | Ownership check missing on GET /documents/{id}; dangerouslySetInnerHTML on user bio; upload served executable from app origin |
| Medium | 4.0–6.9 | Meaningful weakening requiring chaining or limited impact: CSRF on non-critical state, ReDoS/resource exhaustion, missing rate limit on login, verbose errors leaking internals, weak-parameter argon2/bcrypt, missing security headers on sensitive pages, log injection | No lockout on login; stack traces in prod 500s; SameSite unset with no CSRF token but Origin checked |
| Low | 0.1–3.9 | Hardening gaps and defense-in-depth misses with no direct exploit: missing __Host- prefix, Server header exposure, report-only CSP, unmasked PII in internal logs, missing Vary: Origin |
Cookie lacks prefix; HSTS missing includeSubDomains; EXIF not stripped |
Adjust one band up/down for context: data sensitivity (health/financial ↑), internet-exposed vs internal-only (↓ one max — network position is not identity), existing compensating control (↓), trivially scriptable at scale (↑).
Finding format
[SEVERITY] <title>
File: <path>:<line> (every claim anchored to file:line)
CWE: CWE-<id> (<name>) (omit only if genuinely unmapped)
Source → Sink: <where attacker data enters> → <dangerous operation>
Exploit scenario: <concrete attacker story: who, sends what, gets what>
Fix: <specific change, referencing the rules/ section with the pattern>
Order the report Critical→Low; lead with a one-paragraph executive summary (counts by severity, worst finding, systemic themes). Group repeated instances of one weakness into a single finding listing all locations.
Rules index
| File | Topics | Read this when... |
|---|---|---|
| rules/01-input-injection.md | SQLi/NoSQLi, command & argument injection, path traversal/Zip Slip, SSRF + DNS rebinding, XXE, SSTI, deserialization, prototype pollution, ReDoS, canonicalization, allowlist validation | ...any external data reaches a query, shell, path, URL fetcher, parser, template, regex, or object loader; writing input validation; auditing any handler |
| rules/02-authentication.md | argon2id parameters, credential-stuffing defense, session lifecycle/fixation, JWT (alg pinning, claims, storage, refresh rotation), OAuth2/OIDC + PKCE, MFA/TOTP, account recovery, passkeys/WebAuthn | ...building or reviewing login, signup, sessions, tokens, SSO, password reset, MFA enrollment, or anything that proves identity |
| rules/03-authorization.md | Deny-by-default enforcement, IDOR/BOLA, function-level authz, RBAC/ABAC/ReBAC, multi-tenant isolation (RLS), confused deputy, authz bypass patterns | ...any endpoint takes an object ID; multi-tenant features; role/permission systems; service-to-service trust; hunting access-control bugs (start here for audits) |
| rules/04-cryptography.md | Algorithm table (AEAD, X25519, Ed25519), nonce discipline, CSPRNG use, key management/rotation/KMS, TLS config & cert verification, constant-time comparison, tamper-evident logs/audit ledgers (keyed chains, anchoring, integrity vs completeness), secrets in code/CI | ...encrypting, signing, hashing, generating tokens, configuring TLS, storing secrets, building or auditing a "tamper-evident"/audit ledger, or you see any crypto primitive or verify=False in code; constant time as a property of the emitted code (§6.1) — secret-dependent division, strength reduction as an optimiser courtesy, the arch × -O matrix |
| rules/05-web-security.md | Context-aware XSS encoding, Trusted Types, nonce-based CSP, CSRF stack, CORS misconfig, clickjacking, header baseline, cookie attributes/prefixes, file upload pipeline | ...rendering user content, setting headers/cookies, configuring CORS, handling uploads, or auditing anything browser-facing |
| rules/06-memory-resource-safety.md | Integer overflow/truncation, bounds & banned C APIs, unsafe/FFI policy, untrusted size fields, decompression bombs, timeouts/rate limits/load shedding, TOCTOU & race-driven bypass | ...parsing binary formats, doing arithmetic on input-derived sizes/money, writing C/C++/unsafe Rust/FFI, or auditing DoS and concurrency surfaces |
| rules/07-data-exposure.md | Leak-free error handling, oracle-free responses, logging redaction & log injection, security event logging, mass assignment, response over-exposure, debug surfaces in prod | ...designing errors/logging, binding request bodies to models, shaping API responses, or auditing what an attacker learns from outputs |
| rules/08-llm-ai-security.md | Prompt injection (direct/indirect), lethal trifecta, dual-LLM/taint gating, tool-call authorization & human-in-the-loop, model output as untrusted data, RAG ACLs, model supply chain | ...building or auditing anything with an LLM: agents, tool calling, RAG, chat UIs rendering model output, MCP servers, prompt/completion logging |
| rules/09-untrusted-data-ingestion.md | Hostile data feeds/content; ingest as a trust boundary, provenance/taint tagging; sandboxed parsers (image/archive/PDF/Office/XML/CSV/JSON, fuzzy-hash); zip-slip/zip-bomb/pixel-bomb/decompression caps; size/rate/timeout DoS controls, quarantine/DLQ; parse-don't-validate, MIME sniffing, polyglots, AV; feed integrity & broker pattern | ...ingesting attacker-authored external data — threat-intel/RSS feeds, scraped content, user uploads, third-party webhooks/APIs, RAG corpora, email, file imports — through parsers into storage/UI; auditing collectors, upload endpoints, or feed pipelines |
| rules/10-silent-control-failure.md | Controls that look enabled and do nothing: the falsification question, weak existence checks, optional-dependency degradation, empty/placeholder rulesets, swallowed enforcement exceptions, overloaded flags, early-return and truncation bypasses (into an inspector or out of a generator), silently-ignored config keys, doc/code default drift, unearned claims in output — the numbers and the verification words (verified/reachable/tainted, severity from a constant), shipped-artifact gaps, prompt/instruction standing in for an enforced control (attention leakage), a gate whose trigger never fires (a skipped job reports Success), a control parked in audit/warn/dry-run/report-only mode; the degraded-control helper; absence-claim evidence (the mutation probe itself moved to rules/12) |
...you are about to trust that a control is working — any audit pass over controls that exist, any build where a safeguard's failure would be invisible, any "it's enabled" claim from a banner, config, or green test |
| rules/11-dead-path-diagnostics.md | Finding the above at codebase scale: duration-not-result, printing every gate's denominator (0 checked, 0 failed, exit 0), cross-scale delta, telemetry silence, the provenance of an analysis's rows — a sink that tests and production both write is two populations, and the contaminated aggregate carries the larger n, proving a fix executed; scale-dependent silence (unbounded traversal, size-gated paths fixtures never cross, budgets that truncate coverage silently); stale-artifact no-ops (a cache/tag key narrower than the behaviour); format assumptions from one sample + lenient parsers returning plausible-but-wrong values; contract drift by interaction — the producer/consumer seam no schema declares; location-dependent silence — a filter matching the ambient environment (absolute path, hostname) so a collection is correct on one machine and empty on another; asserts stripped by -O/NDEBUG/missing -ea; ACTIVE/LATENT/REFUTED evidence labels; running every CI/hook/runbook script before reading any of them; the four-state watcher model (DONE / NOT-DONE / GONE / UNKNOWN — GONE is terminal and knowable, and is the row people delete while fixing the other bug), metamorphic liveness oracles for a tool whose correct output you cannot state (the test oracle problem); closes by handing the tools that produced the findings to rules/15 |
...sweeping a whole system for stages that report success while doing nothing, deciding where to apply rules/10, or validating that a pipeline's "0 findings" means it ran |
| rules/12-verifying-the-verifier.md | Proving a specific control works: the mutation probe — including its commonest failure, a substitution that matched nothing, and asserting the pattern is present BEFORE writing — (no-op the body, watch what fails) with its two traps — a path skipped for an unrelated reason, and a mutation that never landed; the allow arm, because a control that blocks everything reads as one that works; and where the probe lives — a --self-test mode of the tool over a harness beside it, so "every check can go red" is a property of the suite, not of whoever last edited it; the control that was correct and then edited — detection is bounded by the integrity of the detector, manifesting the verifier is necessary and not sufficient, and the fix is location (execute from a copy the constrained principal cannot write to), plus the protocol-level residual where "allow" is silence; the cross-discipline lineage (proof test, positive control, BITE, poka-yoke) |
...whenever you add or review a control and need to know whether anything holds it in place |
| rules/15-instruments-and-guards.md | The guard that correctly declines and says nothing — a conjunction discards which reason applied (§3a) · The instrument you wrote ninety seconds ago — during verification the harness is newer than the subject, so the prior belongs on it, and the tell is an implausible result rather than a red one (sota-code-security rules/16 §2.1) · Distrusting whatever did the proving. Your instrument is a control — scorers, gates, benchmarks and thresholds need a known-bad at the floor and a known-good at the ceiling in CI, a negative control, abort-don't-warn, sample-before-counting, and validation on inputs that can fail; the five instrument-specific failure modes; instruments that run over time (the four watcher states, incl. GONE); evidence the subject supplies about itself; disclosing an instrument changed after results were seen; and the guard that is an instance of what it guards — a predicate the defect satisfies ("auth=" in line passes on auth=None), a guard nested in another gate's success branch, a denominator counting only survivors, per-target kill verification at 100% |
...after rules/10 and rules/11 have produced findings, before any is reported or any number quoted; whenever you write something whose output decides whether something else is OK |
| rules/16-where-no-ops-hide.md | The catalogue split out of rules/10 (ROADMAP 55): sixteen shapes a control takes when it is present, looks enabled and enforces nothing — weak existence checks, degraded optional dependencies, swallowed exceptions on the enforcement path, truncation into an inspector, the control that is not in force, a flag that parses but does nothing, the aggregate that masks a detection. Section numbers unchanged across the move, so sota-code-security` rules/16 §2.7` is still sota-code-security rules/16 §2.7. Read rules/10 first — that is the method, this is what it finds |
|
| rules/13-context-dependent-silence.md | The five classes rules/10 does not cover, split out of rules/11 §3 — each one correct under the condition you tested and silently wrong under the one you shipped into: scale-dependent silence (a size-gated path no fixture crosses), the stale-artifact no-op (a cache key narrower than the behaviour), a format assumption generalised from one sample, contract drift at a seam neither side declared — including the seam whose producer is a model, where the defaulted read .get(k, default) turns a key-name disagreement into a plausible constant and only a runtime unconsumed-key diff can close it — and location-dependent silence (correct here, empty there) |
...a diagnostic from rules/11 sota-code-security rules/16 §2 fired — a suspicious duration, a denominator that will not move, telemetry that went quiet — and you need the class behind it; also before trusting any result measured in one environment, at one scale, against one sample |
| rules/14-control-not-in-force.md | The other half of rules/10: not a control that runs and does nothing, but one that is not there — unearned claims in reporting output (the numbers and the verification words), shipped-artifact gaps, a natural-language instruction standing in for an enforced control, a control that never executes (a skipped job reports Success), and one parked in audit/warn/dry-run/report-only mode | ...you have read the control's body and it looks right; these are found by asking what ships, what fires, and what the output is entitled to say — never by reading the control itself; the stage that reports success is not the stage that failed (§4b) — discovery vs collection, and proving a repair by output delta rather than a green light |
Top-10 non-negotiables
Violations of these are findings regardless of context; in BUILD mode they are never acceptable shortcuts:
- Every SQL/NoSQL value parameterized — no string-built queries, raw-query escape hatches audited, identifiers allowlist-mapped. (CWE-89)
- No shell string execution — argv arrays only,
--separators, noshell=True/exec(string). (CWE-78) - No native deserialization of untrusted data — no pickle / ObjectInputStream / unserialize / Marshal / yaml.load; data-only formats + schema. (CWE-502)
- Object-level authz on every ID the client supplies — ownership/tenant predicate inside the query, deny by default, 404 for unauthorized. (CWE-639/862)
- Passwords only as argon2id (or scrypt/bcrypt) hashes at current parameters; login/reset rate-limited with uniform errors. (CWE-916/307)
- JWT verification pins algorithms and checks
exp/iss/aud; OAuth is Authorization Code + PKCE with exact redirect URIs; tokens never in localStorage or URLs. (CWE-347) - No hardcoded or client-shipped secrets, no disabled TLS verification — CSPRNG for all tokens, constant-time comparison for all secret checks. (CWE-798/295/330/208)
- All user-influenced output encoded/sanitized for its sink — HTML context encoding + allowlist sanitizer for rich text; applies equally to LLM output. (CWE-79)
- Outbound fetch of user-supplied URLs gets full SSRF defense — scheme allowlist, post-resolution private-IP block, pinned connection, redirect re-validation. (CWE-918)
- LLM tool calls authorized in code against the human principal — session-bound scoping, schema-validated arguments, human confirmation for irreversible actions; prompts are never the security boundary. (CWE-863)
Files (sota-skills)
-
rules
-
01-input-injection.md 15.6 KB
# 01 — Input Handling & Injection Scope: SQL/NoSQL injection, command injection, path traversal, SSRF, XXE, template injection, unsafe deserialization, prototype pollution, ReDoS, canonicalization, allowlist validation. Maps to OWASP A05:2025 (Injection), A01:2025 (Broken Access Control — SSRF was folded into it in the 2025 release), A08:2025 (Software or Data Integrity Failures). Core principle: **data and code must never share a channel.** Every injection class below is the same bug — untrusted bytes reaching an interpreter (SQL engine, shell, filesystem path resolver, XML parser, template engine, object loader, regex engine) without a structural boundary. Fix the boundary; never "sanitize" your way out. ## 1. Validation strategy (applies to everything below) - Validate at the **trust boundary** (HTTP handler, queue consumer, file ingester), not deep in business logic. Reject early, fail closed. - **Allowlist, never denylist** (CWE-183). Define what IS valid (type, length, range, charset, format) and reject everything else. Denylists always miss an encoding. - **Canonicalize before validating** (CWE-180): decode URL/percent/unicode encoding ONCE, normalize unicode (NFC), resolve paths — then validate the canonical form. Validating then decoding reintroduces the bug (`%252e%252e%252f`). - Validate server-side. Client-side validation is UX, not security. - Length-limit every string input. Unbounded input is a DoS primitive even when syntactically valid. - **Validate semantics, not just syntax** (OWASP Business Logic): enforce cross-field and business invariants the type system can't (`checkout` after `checkin`, `quantity ≥ 1`, end-date after start-date, currency matches account). A well-formed-but-nonsensical request is still an attack. - **Anything the client can set is adversary-controlled** — hidden form fields, disabled inputs, pre-filled values, and data you returned last response included. Re-validate and re-authorize them server-side; never trust them because "the UI doesn't allow changing it". ```python # BAD: denylist, validates pre-decoding if "../" not in user_path: open(base + urllib.parse.unquote(user_path)) # GOOD: canonicalize, then containment check (allowlist semantics) p = (BASE_DIR / user_path).resolve() if not p.is_relative_to(BASE_DIR): raise Forbidden() ``` ## 2. SQL injection (CWE-89) - Use **parameterized queries / prepared statements** for every value. No exceptions for "internal" or "already validated" data. - String concatenation/f-strings/format() into SQL is a finding even if inputs look safe today — the call site is one refactor away from exploitable. - Identifiers (table/column names, ORDER BY direction) **cannot be parameterized**: map them through a hardcoded allowlist dict, never interpolate raw input. - ORMs do not save you: `raw()`, `extra()`, `whereRaw()`, `$where`, string-built HQL/JPQL are all injectable. Audit every raw-query escape hatch. - NoSQL (CWE-943): reject non-scalar values where scalars are expected. `{"password": {"$ne": ""}}` bypasses Mongo equality checks — enforce types (`typeof password === "string"`) before building queries. - LIKE clauses: escape `%` and `_` in the *value* (parameterization doesn't), or attacker controls match breadth. ```python # BAD cur.execute(f"SELECT * FROM users WHERE email = '{email}'") cur.execute("SELECT * FROM logs ORDER BY " + sort_col) # GOOD cur.execute("SELECT * FROM users WHERE email = %s", (email,)) SORT_COLS = {"date": "created_at", "name": "username"} cur.execute(f"SELECT * FROM logs ORDER BY {SORT_COLS[sort_key]}") # KeyError = reject ``` ## 3. Command injection (CWE-78) - **Do not invoke a shell.** Use argv-array exec APIs: `subprocess.run([...])` (never `shell=True`), `execve`, Go `exec.Command`, Node `execFile`/`spawn` (never `exec`). - Prefer native libraries over shelling out (`shutil`, `os` ops, language HTTP/zip libs) — removes the interpreter entirely. - Argv arrays stop shell metacharacters but NOT argument injection (CWE-88): a filename of `--output=/etc/cron.d/x` or `-oProxyCommand=...` (ssh, git, curl, tar, find all have dangerous flags). Insert `--` before positional args and validate that user-supplied args don't start with `-`. - If a shell is truly unavoidable, allowlist-validate every interpolated token against `^[A-Za-z0-9._-]+$` and still treat it as a code smell. ```js // BAD exec(`convert ${file} out.png`); // GOOD execFile("convert", ["--", file, "out.png"]); ``` ## 4. Path traversal (CWE-22) & file access - Resolve to an absolute canonical path (`realpath`, `Path.resolve`, `filepath.Clean` + abs) and verify containment within the intended base dir with a path-aware check (`is_relative_to`), not `startswith` (`/var/www2` passes a `startswith("/var/www")` check). - Reject NUL bytes, and on Windows also reserved names (`CON`, `NUL`), alternate data streams (`:`), and both slash types. - Treat archive extraction as path traversal (Zip Slip, CWE-22): validate every entry name post-join; reject absolute paths and symlink entries; cap total decompressed size and entry count (zip bombs). - Best: don't use user input as a path at all — store files under a server-generated UUID, keep the user filename only as display metadata in the DB. ### 4.1 Filesystem adjacents - Symlinks inside user-controllable trees escape containment even after `resolve()`-at-check-time — re-resolve at open time or use `O_NOFOLLOW`, `openat2(RESOLVE_BENEATH)` on Linux; see TOCTOU in rules/06 §6. - User-controlled *target* of file writes (log path, export path, config path from input) is arbitrary-file-write → RCE via cron/webroot/`.ssh` drops; same containment rules apply to writes, harder. - `os.path.join(base, user)` discards `base` entirely when `user` is absolute (`/etc/passwd`) — join, then resolve, then containment-check; never trust the join alone (Python, Node `path.join` with `..`, Java `Paths.resolve`). ## 5. SSRF (CWE-918) Any feature that fetches a user-supplied URL (webhooks, importers, PDF renderers, avatar-by-URL, link previews) is an SSRF surface targeting cloud metadata (`169.254.169.254`), internal admin panels, and localhost services. - Allowlist schemes (`https` only — block `file:`, `gopher:`, `ftp:`, `dict:`). - Resolve DNS, then verify **every** resolved IP is public: reject loopback, RFC1918, link-local, ULA/IPv6-mapped (`::ffff:127.0.0.1`), `0.0.0.0`. - **Pin the validated IP for the actual connection** (custom dialer/resolver) — validate-then-reconnect is a DNS-rebinding TOCTOU (CWE-367). - Disable or re-validate redirects (redirect to `http://127.0.0.1/` defeats a one-shot check). Cap redirect count, response size, and timeout. - Defense in depth: run fetchers in an egress-restricted network segment; on cloud, enforce IMDSv2 / metadata-server firewalling. - Parser-confusion URLs (`http://expected.com@evil.com/`, `evil.com#@expected.com`) — compare the *host the client will actually connect to*, from the same URL parser the HTTP client uses. ```go // GOOD: validate the resolved IP and pin it for the dial (no rebinding window) dialer := &net.Dialer{Timeout: 5 * time.Second} transport := &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, _ := net.SplitHostPort(addr) ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } for _, ip := range ips { if ip.IP.IsLoopback() || ip.IP.IsPrivate() || ip.IP.IsLinkLocalUnicast() || ip.IP.IsMulticast() || ip.IP.IsUnspecified() || isCGNAT(ip.IP) { return nil, errors.New("blocked address") // IsMulticast covers 224/4 + ff00::/8; // isCGNAT blocks 100.64.0.0/10 (carrier-grade NAT, reaches internal hosts) } } return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) }, } client := &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 3 { return errors.New("too many redirects") } return nil // transport re-validates each hop's IP via DialContext }} ``` ## 6. XXE & XML (CWE-611) - Disable DTDs and external entities on **every** XML parser, explicitly — many parsers (Java DocumentBuilderFactory, libxml2 pre-2.9) are unsafe by default: `factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)`. - Python: use `defusedxml`; .NET: `XmlResolver = null`; Node: avoid `libxmljs` `noent: true`. - Same family: disable external schema/DTD fetch in SVG processing, DOCX/XLSX ingestion (they're zipped XML), SOAP, and SAML libraries. - Billion-laughs (CWE-776): cap entity expansion even with external entities off. ```java // GOOD: Java hardened XML factory (apply to DocumentBuilder, SAX, StAX, Transformer) DocumentBuilderFactory f = DocumentBuilderFactory.newInstance(); f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); f.setFeature("http://xml.org/sax/features/external-general-entities", false); f.setFeature("http://xml.org/sax/features/external-parameter-entities", false); f.setXIncludeAware(false); f.setExpandEntityReferences(false); ``` ## 7. Template injection — SSTI (CWE-1336) - User input goes into template **context variables**, never into the template **string**. `render(template_string + user_input)` is RCE in Jinja2, Freemarker, ERB, Twig, etc. (`{{cycler.__init__.__globals__...}}`). - If users must author templates (email editors, CMS), use a logic-less sandboxed engine (Mustache/Liquid in strict mode, Jinja2 `SandboxedEnvironment` — and treat even that as a hardened surface, sandbox escapes recur). ```python # BAD # GOOD Template("Hi " + name).render() Template("Hi {{ name }}").render(name=name) ``` ## 8. Unsafe deserialization (CWE-502) - **Never deserialize untrusted data with native object serializers**: Python `pickle`/`PyYAML yaml.load`, Java `ObjectInputStream`/XMLDecoder, PHP `unserialize`, Ruby `Marshal`, .NET `BinaryFormatter` (deprecated for this reason). All are remote code execution by design, regardless of gadget hygiene. - Use data-only formats: JSON, protobuf, msgpack — then validate against a schema and map to explicit DTOs. - `yaml.safe_load` only. Java: if legacy ObjectInputStream is unavoidable, enforce `ObjectInputFilter` allowlists (JEP 290) — and still plan migration. - Signed/encrypted blobs (session cookies, view state) only defer the problem: if the key leaks or signing is misconfigured (Rails `secret_key_base`, ASP.NET machineKey), deserialization RCE follows. Keep contents data-only. ## 9. Prototype pollution (CWE-1321, JS/TS) - Recursive merge/extend/clone of attacker-controlled JSON into objects lets `{"__proto__": {"isAdmin": true}}` poison every object. Block keys `__proto__`, `constructor`, `prototype` in any deep-merge; or use `Object.create(null)` / `Map` for attacker-keyed dictionaries. - `JSON.parse` itself is safe; the merge utility is the sink. Audit lodash `merge`/`set`/`defaultsDeep` call sites fed by request bodies, and query-string parsers with bracket syntax (`?a[__proto__][x]=1`). - Mitigate globally with `node --frozen-intrinsics` or `Object.freeze(Object.prototype)` where feasible; treat as defense in depth, not the fix. ## 10. ReDoS (CWE-1333) - Any regex with nested/overlapping quantifiers (`(a+)+`, `(a|a)*`, `(\w+\s?)*`) applied to unbounded input can pin a CPU core with ~40 chars. - Length-cap input before regex matching. Prefer linear-time engines: RE2, Rust `regex`, Go `regexp` (all guaranteed linear); .NET `NonBacktracking`; Node ≥20 has no built-in guard — use `re2` package for untrusted input. - Lint with rules like `eslint-plugin-redos` / `regexploit` in CI for any regex whose input crosses a trust boundary. - Don't validate emails/URLs with elaborate regexes at all — parse with a real parser, regex only for coarse shape. ## 11. Other injection surfaces (audit sweep list) - **LDAP injection (CWE-90)**: escape DN and filter metacharacters per RFC 4515 (`* ( ) \ NUL`) via the library's escaper, or allowlist `^[A-Za-z0-9._@-]+$` for usernames before building filters. `(&(uid=USER)(password=PASS))` with `USER = *)(uid=*` is an auth bypass. - **XPath injection (CWE-643)**: same shape as SQLi; use parameterized XPath (XPath 3.1 variables) or allowlist values — quoting alone is fragile. - **CRLF / header injection (CWE-93/113)**: reject `\r`/`\n` in anything placed into HTTP headers (redirect `Location` from input, custom headers, cookies) — response splitting and cache poisoning. Modern frameworks reject; hand-built responses and raw socket code don't. Same bug in email: user input in `Subject`/`To` enables SMTP header injection (`%0aBcc: victims`) — use the mail library's structured API, never string-assembled MIME. - **Open redirect (CWE-601)**: `?next=` targets must be relative-path-only (reject `//evil.com`, `https:`, `\\`, scheme-relative) or exact-match against an allowlist. Open redirects chain into OAuth token theft and SSRF-filter bypass — not "low severity" in those contexts. - **CSV/formula injection (CWE-1236)**: cells starting `= + - @ \t` execute in spreadsheet apps on export; prefix with `'` or space-escape when generating CSV/XLSX from user data. - **Host header attacks**: never build absolute URLs (password-reset links!) from the request `Host`/`X-Forwarded-Host` — use a configured canonical origin. Poisoned reset links = account takeover (CWE-640 chain). - **HTTP parameter pollution / parser differentials**: duplicate keys (`?id=1&id=2`), JSON duplicate fields, and content-type confusion are validated-by-one-parser, consumed-by-another bypasses — validate the same representation you consume, normalize once at the boundary. - **GraphQL**: injection rules apply inside resolvers (resolver args → SQL); plus GraphQL-specific limits live in rules/06 §5 and field authz in rules/03. ## Audit checklist - [ ] Is every SQL/NoSQL query parameterized, with raw/`whereRaw`/`$where` escape hatches audited and identifiers allowlist-mapped? - [ ] Are all process invocations argv-array based (no `shell=True`/`exec(string)`), with `--` separators and leading-dash rejection for user args? - [ ] Are file paths canonicalized (`realpath`) then containment-checked with a path-aware comparison before any filesystem access? - [ ] Does archive extraction validate entry paths, reject symlinks/absolute entries, and cap decompressed size? - [ ] Do URL fetchers enforce scheme allowlist, block private/link-local/metadata IPs *post-DNS-resolution*, pin the connection IP, and re-check on redirects? - [ ] Are DTDs/external entities explicitly disabled on every XML/SVG/Office-doc parser? - [ ] Is user input confined to template variables (never concatenated into template strings)? - [ ] Is all untrusted deserialization via data-only formats with schema validation (no pickle/ObjectInputStream/unserialize/Marshal/yaml.load)? - [ ] Do JS deep-merge/set utilities fed by request data block `__proto__`/`constructor`/`prototype` keys? - [ ] Are regexes on untrusted input linear-time or length-capped, with no nested quantifiers? - [ ] Is validation allowlist-based, server-side, performed after canonicalization, with length limits on every field? - [ ] Are CR/LF rejected from header/email-field values, and absolute URLs (reset links) built from configured origins, never the Host header? - [ ] Are redirect targets allowlisted or relative-only, and CSV exports formula-escaped? - [ ] Are LDAP/XPath filters built with library escapers or parameterization? -
02-authentication.md 15.1 KB
# 02 — Authentication, Sessions, Tokens Scope: password storage, MFA, session management, JWT, OAuth2/OIDC, passkeys. Maps to OWASP A07:2025 (Authentication Failures), CWE-287 family. Core principle: **never design your own authentication protocol.** Compose vetted primitives (argon2id, OIDC, WebAuthn) and framework session machinery. Every hand-rolled "remember me" token, password reset scheme, or HMAC login dance is a finding until proven otherwise. ## 1. Password storage (CWE-916, CWE-256) - Hash with **argon2id**. 2026 baseline parameters: memory ≥ 64 MiB (`m=65536`), iterations `t=3`, parallelism `p=4` (or OWASP minimum `m=19456, t=2, p=1` where memory-constrained — tune to ~0.5s on your hardware). Fallback order: scrypt (N=2^17, r=8, p=1) → bcrypt (cost ≥ 12, beware 72-byte truncation — reject longer passwords rather than pre-hashing). - Salts: per-password, random, generated by the library. Pepper (server-side secret mixed in via HMAC before hashing) is worthwhile if you can store it outside the DB (KMS/HSM/env) — protects against DB-only exfiltration. - Never: MD5/SHA-1/SHA-256(password+salt) — fast hashes are offline-crackable at billions/sec (CWE-916). Never reversible encryption. Never log or store plaintext transiently in queryable form. - Verify with the library's `verify()` (constant-time); rehash-on-login when parameters are below current policy. - Password policy (NIST SP 800-63B-4, final Aug 2025): length ≥ 15 when the password is the sole factor (≥ 8 permitted only as part of MFA), allow 64+, allow all printable chars + unicode, **no composition rules, no periodic rotation**. Check candidates against a breach corpus (k-anonymity HIBP API). - Rate-limit and lockout: per-account *and* per-IP throttling, with exponential backoff or CAPTCHA escalation — credential stuffing is the dominant attack (CWE-307). Return the **same error and similar timing** for "no such user" vs "wrong password" (account enumeration, CWE-204); apply equally to registration and password-reset responses. ```python # GOOD from argon2 import PasswordHasher ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4) hash = ph.hash(password) ph.verify(hash, attempt) # raises on mismatch, constant-time if ph.check_needs_rehash(hash): store(ph.hash(attempt)) ``` ## 2. Session management (CWE-384, CWE-613) - Use the framework's session implementation. Session IDs: ≥ 128 bits from a CSPRNG, opaque (no encoded user data), stored server-side or in a sealed cookie. - **Regenerate the session ID on every privilege change**: login, logout, password change, MFA step-up, role elevation. Reusing the pre-auth ID = session fixation (CWE-384). - Cookie flags: `Secure; HttpOnly; SameSite=Lax` (or `Strict`), `__Host-` prefix (enforces Secure + no Domain attribute + Path=/). Details in rules/05. - Expiry: idle timeout (15–30 min sensitive apps, ≤ 24h general) AND absolute timeout (e.g. 8–12h) regardless of activity (CWE-613). Logout must invalidate **server-side**, not just clear the cookie. - On password change or "log out everywhere": revoke all of the user's sessions. Maintain a session registry to make this possible. - **Renewal timeout**: regenerate the session ID periodically mid-session (e.g. every few hours) even without a privilege change, capping the window a stolen ID is useful. - On logout/sensitive responses, send `Clear-Site-Data: "cookies", "storage"` and `Cache-Control: no-store` so the session artifact isn't left in the browser/proxy cache. - Bind nothing secret into URLs: session tokens in query strings leak via logs, referrers, and browser history (CWE-598). - "Remember me" done right: a separate long-lived token, never an extended session — `selector:validator` pattern (selector indexes the row, validator is compared against its **hash** constant-time), single-use rotation on each login, revoked with the session family on password change. A long-lived token granting full session powers without re-auth for sensitive ops is a finding; pair with step-up auth (§5). - Concurrent-session policy is product-specific, but display active sessions (device, IP, last seen) and let users revoke them — detection beats prevention for stolen sessions. Optionally bind sessions to coarse client properties (IP range/UA family) and step-up on anomaly rather than hard-fail. ```python # GOOD: remember-me verification (selector/validator, hashed at rest) row = db.get_remember_token(selector) if row and not row.expired and hmac.compare_digest( hashlib.sha256(validator).digest(), row.validator_hash): rotate_remember_token(row) # single use login_user(row.user_id, fresh=False) # mark non-fresh: step-up for sensitive ops ``` ## 3. JWT pitfalls (CWE-345, CWE-347) JWTs are misconfiguration magnets. If sessions are server-side anyway, prefer opaque tokens. If you use JWTs: - **Pin the algorithm at verification.** Pass an explicit allowlist (`algorithms=["EdDSA"]` or `["RS256"]`); never trust the header's `alg`. Classic breaks: `alg: none` acceptance, and RS256→HS256 confusion where the public key is used as an HMAC secret (CWE-347). - Prefer asymmetric (EdDSA/Ed25519 or ES256) when multiple services verify — shared HMAC secrets turn every verifier into a forger. HS256 secrets must be ≥ 256 bits random, never a password. - **Always set and verify `exp`** (short: 5–15 min for access tokens), plus `iss`, `aud`, `nbf`. Verifying signature but not claims is a common library default trap. - Revocation: JWTs can't be revoked, so keep them short-lived and pair with rotating refresh tokens (server-side, revocable, **rotation with reuse detection** — a replayed old refresh token revokes the whole family). - `kid`/`jku`/`x5u` header fields: treat as untrusted input. `kid` → lookup in your own keystore only (SQLi/path traversal via `kid` is a known pattern); `jku`/`x5u` → allowlist of your own JWKS URLs or reject. - Never put secrets/PII in the payload — it's base64, not encrypted. - Browser storage: keep tokens out of `localStorage` (XSS-exfiltratable, CWE-922). Use `HttpOnly` cookies, or in-memory only with refresh via HttpOnly cookie. ```js // BAD: library honors header alg, no claim checks jwt.verify(token, key); // GOOD jwt.verify(token, publicKey, { algorithms: ["EdDSA"], issuer: ISS, audience: AUD, maxAge: "15m" }); ``` ## 4. OAuth 2.0 / OIDC (correct flows) - Authorization Code flow **with PKCE (S256)** for every client type — SPAs, native apps, and confidential web apps (OAuth 2.1 direction). Implicit flow and Resource Owner Password Credentials are deprecated; flag them on sight. - `state` parameter: random, bound to session, verified on callback (CSRF on the authorization response). PKCE covers code injection; `state` still covers login-CSRF — use both. OIDC: also verify `nonce` in the ID token. - **Exact-match redirect URI validation** on the server; wildcard or prefix-match redirect URIs enable token theft via open redirect chains. - Validate ID tokens fully: signature against the IdP JWKS, `iss`, `aud` (= your client_id), `exp`, `nonce`. Never accept an ID token as an API access credential, and never authenticate via an unverified `email` claim from a token meant for another client (audience confusion). - Don't use OAuth access tokens alone as login proof ("login with X" by calling /userinfo with any valid token) — token may have been issued to a different, malicious app. Use OIDC ID tokens with `aud` checks. - Secrets: client secrets never in mobile/SPA bundles (they're public clients — that's what PKCE is for). ```text # Authorization Code + PKCE, the parts implementations get wrong: 1. client: verifier = base64url(rand(32)); challenge = base64url(SHA256(verifier)) 2. /authorize?...&code_challenge=challenge&code_challenge_method=S256 # never "plain" &state=<random, bound to session>&nonce=<random, bound to session> # both 3. callback: verify state matches session BEFORE using code 4. /token with code + verifier (server recomputes & compares) 5. validate ID token: sig (IdP JWKS, kid from your cached set), iss, aud=client_id, exp, nonce matches session; only then create the local session (regenerate ID) ``` ### 4.1 SAML (when you must) - Use a maintained library; validate the **signature before** parsing assertions, on the whole document with a fixed expected structure — XML signature wrapping attacks splice unsigned assertions next to signed ones (CWE-347). Reject multiple Assertion elements and comment-node tricks inside NameID (truncation bypasses). - Validate `Recipient`, `Audience`, `InResponseTo`, `NotOnOrAfter` (clock-skew bounded), enforce single-use assertion IDs (replay cache). XXE hardening from rules/01 §6 applies to the SAML parser itself. - Prefer OIDC over SAML for new integrations; SAML's flexibility is its CVE generator. ## 5. MFA & account recovery - Support phishing-resistant factors first: **passkeys/WebAuthn**, then TOTP. SMS OTP is last resort (SIM-swap, SS7); never the only factor for high-value ops. - TOTP: secret ≥ 160 bits, ±1 time-step window max, **rate-limit verification** (6 digits = 10^6 space, brute-forceable without throttling), prevent code reuse within its window. - Step-up authentication for sensitive actions (payout, email change, recovery settings) even within an authenticated session. - **Account recovery must be as strong as login** — a password reset email that bypasses MFA nullifies MFA (CWE-640). Reset tokens: ≥ 128-bit random, single-use, ≤ 1h expiry, stored hashed, invalidated on use *and* on password change; don't reveal account existence in the response. - Recovery codes: one-time, hashed at rest, regenerable, shown once. - MFA enrollment changes require re-authentication and notify the user out-of-band. ## 6. Passkeys / WebAuthn - Use a maintained server library (e.g. SimpleWebAuthn, webauthn4j) — do not hand-parse CBOR/attestation. - Verify on every ceremony: `challenge` (random per ceremony, single-use, server-stored), `origin` (exact match against your RP origins), `rpIdHash`, user-presence/user-verification flags per your policy (`userVerification: "required"` if passkey is single-factor login). - Check and update the **signature counter**; a counter regression signals a cloned authenticator (log/step-up; many synced passkeys legitimately report 0 — policy, not hard-block). - Store: credential ID, public key, counter, transports — per user, allowing multiple credentials. Bind credential to user atomically at registration. - Attestation: "none" is fine for consumer apps; only enforce attestation allowlists when you genuinely require certified hardware. ## 7. Signup, verification & account linking - Email verification tokens follow reset-token rules (§5): ≥128-bit random, hashed at rest, single-use, short expiry. Gate sensitive features on verified status, and store the verification state per address — changing email resets it. - **Pre-account-takeover / unverified linking (CWE-1390 family)**: attacker signs up with victim's email (unverified); victim later does "Sign in with Google" using that email; sloppy linking merges them and the attacker's password still works. Rules: never auto-link a social login to an existing local account by email match unless the IdP asserts `email_verified` AND your local account's email is verified; on link, kill existing sessions and notify; re-authenticate before adding any new login method. - Email change is an account-takeover primitive: confirm via **both** the old address (revocable notice) and the new (verification link); re-auth + MFA step-up first; keep the old address as recovery for a grace window. - Phone verification: rate-limit sends per number AND per requester (SMS pumping fraud, see rules/06 denial-of-wallet), expire codes ≤10 min, rate-limit attempts (§5 TOTP rules apply). - Signup anti-automation: proof-of-work/CAPTCHA escalation under abuse, disposable-email policy per product needs, and uniform "if an account exists, we sent a mail" responses (enumeration, §1). ## 8. API keys & machine credentials - API keys are passwords for machines: generate ≥ 256-bit from CSPRNG with a recognizable prefix for secret scanning (`sk_live_...` pattern enables GitHub/gitleaks detection), **store only a hash** (SHA-256 is fine — keys are high-entropy, no argon2 needed), show plaintext once at creation. - Lookup pattern: split `keyid.secret` so the DB lookup uses the non-secret ID and the secret compares constant-time against the hash — avoids timing-oracle full-table scans. - Scope and expire: per-key permissions (read-only default), per-key rate limits, expiry dates, last-used tracking, instant revocation, and rotation without downtime (two active keys during overlap). - Service-to-service: prefer short-lived, asymmetric, workload-bound credentials over static keys — mTLS/SPIFFE identities, cloud workload-identity federation (OIDC), signed tokens with `aud` per target service. Static bearer keys in env vars are the floor, not the goal. - Webhook verification (inbound machine auth): verify HMAC signatures (constant-time) over the **raw body** with a per-source secret, enforce a timestamp window against replay, and reject before parsing. Outbound: sign your webhooks so consumers can do the same. ## Audit checklist - [ ] Are passwords hashed with argon2id (or scrypt/bcrypt) at current-policy parameters, with rehash-on-login? - [ ] Are login, registration, and reset endpoints rate-limited with uniform errors/timing across "user exists" states? - [ ] Is the session ID regenerated at login and every privilege change, and invalidated server-side at logout/password change? - [ ] Do sessions have both idle and absolute timeouts, with a registry enabling "revoke all"? - [ ] Does every JWT verification pin an algorithm allowlist and check `exp`, `iss`, `aud`? - [ ] Are access tokens short-lived with rotating, reuse-detecting refresh tokens? - [ ] Are tokens kept out of localStorage and URLs? - [ ] Do all OAuth flows use Authorization Code + PKCE(S256), with `state`/`nonce` verified and exact-match redirect URIs? - [ ] Is account recovery MFA-equivalent strength, with single-use hashed short-lived reset tokens? - [ ] Is TOTP verification rate-limited and code reuse blocked? - [ ] Do WebAuthn ceremonies verify challenge, origin, rpId, and UV flags via a maintained library? - [ ] Are "remember me" tokens selector/validator-hashed, single-use, and non-fresh (step-up required for sensitive ops)? - [ ] Are SAML responses signature-validated structurally (wrapping-resistant), with audience/recipient/replay checks? - [ ] Are API keys hashed at rest, scoped, expiring, revocable, and verified constant-time; webhooks HMAC-verified over the raw body with replay windows? - [ ] Does social-login linking require verified email on both sides (or explicit re-auth), with session revocation and notification on link? - [ ] Does email change require step-up auth plus confirmation via old and new addresses? - [ ] Are there zero hand-rolled token schemes, password hashes, or login protocols? -
03-authorization.md 13.8 KB
# 03 — Authorization & Access Control Scope: object-level authorization (IDOR/BOLA), function-level access control, RBAC/ABAC/ReBAC, deny-by-default, multi-tenant isolation, confused deputy. Maps to OWASP A01:2025 (Broken Access Control — still the #1 web risk, and since the 2025 release also home to SSRF), API1:2023 (BOLA), API5:2023 (BFLA), CWE-862/863/639/284. Core principle: **authentication says who you are; authorization must be checked again for every object and every operation.** The most common real-world vuln class is not injection — it is a handler that fetches by ID and forgets to ask "does *this* user own *this* row?" ## 1. Deny by default (CWE-862) - Every route/handler/RPC requires an explicit authorization decision; absence of a check = denied, enforced by middleware/framework, not by convention. An unannotated endpoint should fail closed or fail CI. - Centralize policy in one enforcement layer (middleware, policy engine, service decorators). Scattered inline `if user.role == "admin"` checks drift and get missed on new endpoints. - Apply to **all** entry points: REST, GraphQL resolvers (each field/resolver, not just the query root), WebSocket messages, gRPC methods, background-job enqueue endpoints, and "internal" admin routes (CWE-425 — forced browsing; hidden ≠ protected). - Authorize HTTP methods independently: `GET /users/1` protected but `PATCH /users/1` open is a classic miss; so are method-override headers (`X-HTTP-Method-Override`). - GraphQL needs resolver-level enforcement because one endpoint serves every shape — route-level middleware sees only `/graphql`: ```js // GOOD: authz attached to the field, evaluated per resolution salary: { type: GraphQLFloat, resolve: requireAuthz("employee:salary:read", // permission, not role (emp, _, ctx) => ctx.authz.sameOrgAndHR(ctx.user, emp))(salaryResolver), } // also: depth/complexity limits and introspection gating -> rules/06 §5, rules/07 §4 ``` ```python # GOOD pattern: framework-level default-deny @app.before_request def enforce(): rule = ROUTE_POLICY.get(request.endpoint) # no entry -> deny if rule is None or not rule.allows(current_user, request): abort(403) ``` ## 2. Object-level authorization — IDOR/BOLA (CWE-639) - Every fetch/update/delete by identifier must verify the caller's relationship to **that specific object** — ownership, tenant membership, or an explicit grant. Role checks alone don't cut it: "any authenticated user" + sequential IDs = full data dump. - Encode the check in the query itself so it cannot be skipped: ```python # BAD: fetch then (maybe) check doc = Document.get(doc_id) return doc # whose doc? # GOOD: ownership is part of the lookup; absence = 404 doc = Document.get(id=doc_id, owner_id=current_user.id) # or tenant_id=... if doc is None: abort(404) # don't leak existence with 403 vs 404 ``` - Audit every place an ID arrives: path params, query strings, JSON bodies (including nested IDs like `{"comment": {"post_id": ...}}`), bulk endpoints, export/report jobs, file-download handlers, and ID arrays in batch operations (each element needs the check). - Random IDs (UUIDv4) reduce enumerability but are **not** authorization (CWE-340 misuse). Treat guessable-vs-random as defense in depth only. - Indirect references: where practical, scope all queries through the user's own collection (`current_user.documents.find(id)`) so there is no unscoped accessor to misuse. ## 3. Function-level authorization — BFLA (CWE-863) - Verify the caller may perform the *operation*, not just see the object: a user who can read an invoice must not be able to call `POST /invoices/{id}/refund`. - Don't trust client-supplied role/privilege fields — role comes from the server-side session/token claims validated against the DB, never from a request body (`{"role": "admin"}` mass assignment, see rules/07) or a client-set header (`X-Admin: true`). - State-machine authorization: actions valid only in certain states (approve own expense report, re-trigger completed payment) need state checks server-side — workflow bypass is an authz bug. Enforce the full doctrine (OWASP Business Logic): validate the current state on every step and reject out-of-order transitions; **mark one-time operations consumed** so a captured request can't be replayed (payment capture, coupon redemption, password-reset token); expire abandoned partial-workflow state; and **never store the workflow position in a client-readable/ writable field** — keep it server-side keyed to the session/resource. ## 4. Model choice: RBAC / ABAC / ReBAC - **RBAC**: roles → permission sets. Right default for small/medium apps. Rules: permissions checked, not role names (`can(user, "invoice:refund")`, not `role == "admin"`) so roles can evolve; no permission accumulation across role changes (recompute, don't append); admin roles audited and minimal. - **ABAC**: policy over attributes (user dept, resource classification, time, device). Use when context matters; keep policies in one engine (OPA/Rego, Cedar, Casbin), versioned and tested like code: ```cedar // Cedar: explicit, testable, deny-by-default (no permit -> deny) permit (principal, action == Action::"invoice:read", resource) when { resource.tenant == principal.tenant && (resource.owner == principal || principal.role == Role::"finance") }; forbid (principal, action, resource) when { resource.classification == "restricted" && !principal.cleared }; // forbid overrides permit — encode hard ceilings as forbids ``` - **ReBAC**: relationships as the model ("editor of doc", "member of org that owns folder") — Zanzibar-style (SpiceDB, OpenFGA, Ory Keto). Right answer for sharing/nesting/inheritance (Drive-like products). Beware: relationship-graph traversal depth and negative permissions need explicit design. - Whatever the model: decisions must be **testable in isolation** — a policy test suite asserting allow/deny matrices per role/relationship is an audit requirement, not a nicety. ```python # GOOD: permission check, single policy module, deny-matrix tested def can(user, action: str, resource) -> bool: ... # the ONLY decision API @pytest.mark.parametrize("role,action,owns,expected", [ ("viewer", "invoice:read", True, True), ("viewer", "invoice:refund", True, False), ("admin", "invoice:refund", False, True), ("member", "invoice:read", False, False), # not owner, same tenant -> deny ]) def test_policy_matrix(role, action, owns, expected): ... ``` - Privilege escalation paths to check explicitly: can a user grant themselves a role? Invite themselves to a higher-privileged group? Edit the policy store? Modify their own `tenant_id`/`org_id`? (CWE-269) ## 5. Multi-tenant isolation - Every tenant-owned table carries `tenant_id`; **every query filters on it** — enforce structurally, not by developer discipline: - Postgres Row-Level Security with `SET app.tenant_id` per request, policies `USING (tenant_id = current_setting('app.tenant_id')::uuid)`; or - ORM global scopes/default filters applied from the authenticated context. ```sql -- GOOD: Postgres RLS — isolation enforced even if app code forgets the filter ALTER TABLE documents ENABLE ROW LEVEL SECURITY; ALTER TABLE documents FORCE ROW LEVEL SECURITY; -- applies to table owner too CREATE POLICY tenant_isolation ON documents USING (tenant_id = current_setting('app.tenant_id')::uuid); -- per request, from the AUTHENTICATED context, in the same transaction: SET LOCAL app.tenant_id = '...'; -- and the app role must not be BYPASSRLS / superuser ``` - `tenant_id` derives from the **authenticated session/token only** — never from a request parameter, subdomain string, or header the client controls. - Cross-tenant leak surfaces beyond queries: caches keyed without tenant, search indexes, background jobs that loop over tenants with shared state, signed URLs without tenant scope, sequence-number leakage across tenants, uniqueness checks revealing other tenants' data ("email already exists"). - Test isolation explicitly: an automated test that authenticates as tenant A and replays tenant B's object IDs against every entity type. ## 6. Confused deputy (CWE-441) & service-to-service - A privileged service acting on behalf of a less-privileged caller must carry and enforce the **caller's** authority, not its own: pass the user context (token exchange — OAuth2 RFC 8693, or signed internal context) downstream and re-check authorization at the data-owning service. - "Internal" services trusting any in-network caller is the classic deputy setup: require service identity (mTLS/SPIFFE, signed service tokens) AND per-request user authorization. Network position is not identity (zero trust). - CSRF and SSRF are confused-deputy instances: the browser and your server, respectively, wield ambient authority on an attacker's behalf — same mental model, fixes in rules/05 and rules/01. - Capability URLs / signed URLs: scope them narrowly (object, verb, expiry), treat as bearer credentials (no logging, short TTL), and ensure the signer checks authorization *before* signing. - Cloud IAM deputies: services assuming roles on user requests must use external-id / source-identity conditions so callers can't aim the service's credentials at arbitrary resources. ## 7. Admin & support tooling - Internal/admin panels get **more** scrutiny, not less: they aggregate cross-tenant power. Require SSO + MFA + step-up, network restriction where feasible, and their own authz model (support tier ≠ engineering tier ≠ finance tier) — one "is_staff" boolean is a finding in any non-trivial org. - Impersonation ("login as user") features: explicit grant per use, time-boxed, banner-visible to the operator, **fully audit-logged** (who impersonated whom, when, what they did), and ideally consent- or ticket-gated. Sensitive user actions (password/email change, payouts) blocked during impersonation. - Every admin mutation needs an immutable audit trail (actor, target, before/ after, reason) — both a compliance requirement and your insider-threat and incident-forensics control (pairs with rules/07 §2 security logging). - Break-glass paths (emergency access) must alert loudly and expire; a permanent quiet backdoor "for ops" is indistinguishable from a compromise. ## 8. Background jobs, webhooks & non-interactive paths - Jobs enqueued on behalf of a user carry the **principal**, and the worker re-authorizes at execution time against current grants (revocation between enqueue and run must take effect). Job args are untrusted-ish: validate like request input — queues get written to by more code paths over time. - Scheduled/cron jobs that touch tenant data iterate with per-tenant scoping (RLS context set per tenant inside the loop) — a cross-tenant batch bug is a Critical with no request log to find it by. - Inbound webhooks authenticate the **sender** (HMAC, rules/02 §8) and then still authorize the *claimed subject*: a valid Stripe signature on an event naming `customer_X` doesn't mean your handler should mutate `customer_Y` from a spoofable field — map external IDs to internal rows through owned associations. - Internal/ops endpoints triggered by schedulers or service meshes: service identity required (mTLS), no "trusted because port 8081" assumptions. ## 9. Common bypass patterns to hunt in audits - Authorization done in the controller but a second code path (GraphQL, legacy v1 API, mobile BFF, gRPC) hits the same model unchecked. - Check on read, none on write (or vice versa); none on `HEAD`/`OPTIONS`-routed handlers. - Authz before async work, none when the job executes (job args carry user IDs — re-verify at execution time; grants may have been revoked). - Cache poisoning of authz decisions: decision cached on user ID but not object, or cached across tenants. - Fail-open exception handling: policy-engine timeout / lookup error → `except: pass` → allow (CWE-636). Authorization errors must deny. - Replay across environments: staging tokens accepted in prod (shared signing keys, missing `aud`/`iss` environment binding). ## Audit checklist - [ ] Is there a single default-deny enforcement layer covering REST, GraphQL resolvers, WebSocket, gRPC, and admin routes? - [ ] Does every object lookup by client-supplied ID include an ownership/tenant predicate in the query itself? - [ ] Are nested IDs, batch arrays, exports, downloads, and background-job parameters object-level checked too? - [ ] Are operation-level (function-level) checks distinct from visibility checks? - [ ] Do roles/privileges come exclusively from server-side state — never request bodies or client headers? - [ ] Can no user grant themselves elevated roles, group memberships, or modify their own tenant binding? - [ ] Is tenant filtering enforced structurally (RLS or mandatory ORM scopes) with tenant_id sourced from the session only? - [ ] Are caches, search indexes, signed URLs, and uniqueness errors tenant-scoped? - [ ] Do internal services require both service identity (mTLS) and propagated end-user authorization? - [ ] Do policy-engine failures and exceptions deny (fail closed)? - [ ] Is there an automated cross-tenant / cross-user access test suite asserting the deny matrix? - [ ] Are 404 (not 403) returned for objects the caller cannot see, consistently? - [ ] Do admin tools have tiered roles, MFA/step-up, and immutable audit trails for every mutation? - [ ] Is impersonation time-boxed, logged, visible, and blocked from sensitive account changes? - [ ] Is RLS `FORCE`d with a non-bypass app role where Postgres tenancy is used? - [ ] Do background workers re-authorize the carried principal at execution time, and do webhook handlers map external subjects to internally-owned rows? - [ ] Are hard policy ceilings encoded as forbids/deny rules that override grants? -
04-cryptography.md 24.1 KB
# 04 — Cryptography & Secrets Scope: algorithm selection, AEAD/nonce discipline, key management, randomness, TLS configuration, constant-time comparison, secrets handling. Maps to OWASP A04:2025 (Cryptographic Failures), CWE-327/326/330/321/323/208. Core principle: **don't design, don't implement, barely even compose.** Use a misuse-resistant high-level library (libsodium/NaCl, Tink, age, Go `crypto/*` high-level APIs) and its documented recipes. Hand-assembled crypto (manual IV handling, custom padding, DIY key derivation, homemade protocols) is a finding by default (CWE-1240). ## 1. Algorithm choices (2026 defaults) | Purpose | Use | Never | |---|---|---| | Symmetric encryption | AES-256-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305 (random-nonce safe) | ECB, CBC w/o MAC, RC4, DES/3DES, AES-CTR alone | | Key exchange | X25519 (hybrid w/ ML-KEM-768 for PQ readiness) | static DH < 2048, custom DH params | | Signatures | Ed25519; ECDSA P-256 (deterministic nonce, RFC 6979) where required | RSA-PKCS1v1.5 for new code, DSA | | Hashing (integrity) | SHA-256/SHA-512, BLAKE2/3 | MD5, SHA-1 (CWE-328) | | Password hashing | argon2id (see rules/02) | any fast hash | | KDF from keys | HKDF (per-purpose `info` labels) | raw hash of key material, hash chains | | MAC | HMAC-SHA-256, Poly1305 (within AEAD), KMAC | H(key‖msg) — length extension (CWE-328) | - Encrypt-then-MAC if composing manually — but don't compose manually; use AEAD. - Post-quantum: for long-lived confidentiality (data recorded now, decrypted later), prefer hybrid KEMs (X25519+ML-KEM-768) in TLS/protocol layers where the stack supports it; signatures can wait, harvest-now-decrypt-later can't. NIST IR 8547 (draft) sets the migration clock: 112-bit-security RSA/ECC (RSA-2048, P-256) deprecated after 2030 and all quantum-vulnerable RSA/ECDSA/ECDH/DSA disallowed after 2035 — maintain a cryptographic inventory (CBOM) now so the swap to ML-KEM/ML-DSA/SLH-DSA is a config change, not a rewrite (see §9 crypto agility). ## 2. AEAD and nonce discipline (CWE-323) - **Nonce reuse with the same key in GCM/ChaCha20-Poly1305 is catastrophic**: reveals XOR of plaintexts and (GCM) the auth key → forgeries. - Rules per cipher: - AES-GCM, 96-bit nonce: counter/LFSR per key, or random with a hard cap of ~2^32 encryptions per key (birthday bound). Rotating keys beats counting. - XChaCha20-Poly1305: 192-bit nonce — random nonces safe at any realistic volume. **Default choice when callers pick nonces.** - Or use nonce-misuse-resistant modes: AES-GCM-SIV, where available. - Never derive nonces from timestamps, user IDs, or row IDs alone; never hardcode (CWE-329); never reuse a key across encryption contexts without HKDF separation. - Authenticate context with **associated data (AAD)**: bind ciphertexts to their purpose/record (`aad = user_id || field_name`) so ciphertexts can't be swapped between rows/columns (cryptographic confused deputy). - Decryption failures: uniform error, no padding/MAC distinction surfacing to the caller (padding-oracle family, CWE-209/CWE-203); never act on plaintext before the tag verifies (no streaming-decrypt-then-check). ```python # GOOD: libsodium-style sealed usage from nacl.secret import Aead # XChaCha20-Poly1305 box = Aead(key) ct = box.encrypt(plaintext, aad=record_id) # nonce generated & prepended pt = box.decrypt(ct, aad=record_id) ``` ## 3. Randomness (CWE-330/338) - Security-relevant randomness (keys, tokens, nonces, session IDs, reset codes, CSRF tokens) comes from the OS CSPRNG only: `secrets`/`os.urandom`, `crypto.randomBytes`, `crypto/rand`, `SecureRandom`, `getrandom(2)`. - Findings on sight: `Math.random()`, `random.random()`, `rand()`, Java `java.util.Random`, time-seeded PRNGs, or UUIDv1/v4-from-non-crypto-PRNG used for any credential-like value. - Token entropy ≥ 128 bits; compare tokens constant-time (§6); store long-lived tokens hashed (SHA-256) so a DB leak isn't a credential leak. - Entropy is destroyed by post-processing: `random_string[:6]`, modulo into a small alphabet with bias, or "human-friendly" filtering can collapse 128 bits to brute-forceable space — generate directly in the target alphabet (`secrets.token_urlsafe`, `secrets.choice` loops) and recount bits after. ```python # BAD: 6-digit code via modulo of a 32-bit value — biased AND tiny code = str(struct.unpack("I", os.urandom(4))[0] % 1000000) # GOOD: unbiased, library-managed code = "".join(secrets.choice(string.digits) for _ in range(6)) # + rate limits (rules/02) token = secrets.token_urlsafe(32) # 256-bit URL-safe ``` ## 4. Key management (CWE-320/321/798) - **No hardcoded keys/secrets in source, config files in git, or client-side bundles** (CWE-798). Scan history too — a committed-then-removed key is leaked. - Storage hierarchy (best→acceptable): cloud KMS/HSM (keys never leave; you call encrypt/sign) → secrets manager (Vault/ASM/GSM) with short-TTL dynamic secrets → env vars injected at deploy (last resort; visible in /proc, crash dumps, child processes). - **Key separation**: one key per purpose (encrypt ≠ sign ≠ token-MAC), per environment (prod ≠ staging), derived via HKDF with distinct `info` labels if from a master key. - **Rotation must be designed in from day one**: version every ciphertext/token with a key ID; decrypt with old, encrypt with new; automate rotation cadence and revocation on suspicion. "We can't rotate without downtime" is a finding. - Envelope encryption for data at rest: KMS master key wraps per-object data keys; plaintext data keys held only in memory, zeroized where the language allows. - **Design for key *loss*, not just compromise** (OWASP Key Management): a root key with no recovery path means every ciphertext it protects is gone (the SOPS+age root is exactly this risk — back it up to hardware/escrow). **Back up / escrow data-encryption keys** so encrypted data stays recoverable; **never escrow signing or authentication keys** — a second copy destroys non-repudiation. Store key backups under the same KMS/HSM control as the originals, with their own access audit. - Key material in memory: avoid copies (immutable strings in GC languages spread copies — prefer byte arrays you can zero); never in logs, exceptions, or serialized debug output. ```python # GOOD: envelope encryption with key versioning and AAD context-binding def encrypt_field(plaintext: bytes, record_id: str) -> bytes: dek = secrets.token_bytes(32) # per-object data key wrapped = kms.encrypt(key_id=CURRENT_KEY, plaintext=dek) # master never leaves KMS box = ChaCha20Poly1305(dek) nonce = secrets.token_bytes(12) ct = box.encrypt(nonce, plaintext, record_id.encode()) # AAD = record binding return pack(version=CURRENT_KEY, wrapped=wrapped, nonce=nonce, ct=ct) # decrypt: unpack -> kms.decrypt(wrapped) by version -> open with same AAD # GOOD: per-purpose subkeys from one master via HKDF (never reuse raw master) enc_key = HKDF(master, info=b"app/v1/field-encryption", length=32) mac_key = HKDF(master, info=b"app/v1/url-signing", length=32) ``` ### 4.1 Encrypting data at rest — design notes - Decide the threat first: full-disk/volume encryption defeats stolen disks only; application-layer field encryption defeats DB compromise and curious DBAs — most "encrypt PII" requirements mean the latter. - Deterministic encryption (same plaintext → same ciphertext, for equality-searchability) leaks equality and frequency — confine to low-sensitivity lookup keys, or use blind indexes (HMAC of normalized value, separate key) alongside randomized encryption of the value itself. - Don't encrypt what you can avoid storing; hashing (rules/02) or truncation (last-4 of PAN) beats encryption when you never need the value back. ## 5. TLS configuration (CWE-295/319) - Minimum TLS 1.2 with AEAD ciphers + ECDHE only; prefer TLS 1.3. No CBC suites, no RSA key exchange (no forward secrecy), no renegotiation, no compression (CRIME). - **Certificate verification is never optional**: `verify=False`, `InsecureSkipVerify: true`, `rejectUnauthorized: false`, trust-all TrustManagers, hostname-check disabling — all findings, including in tests that can leak into prod paths (CWE-295). Internal services get a private CA, not disabled verification. - Verify hostname AND chain; pin only when you control update cadence (mobile apps), pin to SPKI of an intermediate/leaf set, with backup pins. - Plaintext fallbacks: no HTTP listeners that serve content (redirect-only), HSTS (rules/05); internal traffic encrypted too — mTLS for service-to-service (identity, not just confidentiality). - Outbound TLS from your code deserves the same scrutiny as inbound config — audit every HTTP client construction for verification overrides. ```nginx # GOOD: server baseline (nginx) — TLS 1.2/1.3, AEAD+ECDHE only ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; ssl_prefer_server_ciphers off; # TLS1.3 best practice: client picks ssl_session_tickets off; # or rotate ticket keys — static keys break FS # no ssl_stapling: OCSP is being retired — Let's Encrypt dropped OCSP URLs from # certs (May 2025) and shut its responders (Aug 2025) in favor of CRLs; enable # stapling only for a CA that still runs OCSP # generate from Mozilla SSL Config Generator ("intermediate") and re-check yearly ``` - Verification-key distribution: publish via versioned key sets (JWKS-style, `kid` on every artifact), cache with TTL, overlap old+new during rotation, and pin the JWKS *endpoint* to your own allowlist (rules/02 §3 `jku` rules). ## 6. Constant-time comparison (CWE-208) - Any comparison where one side is secret (MACs, tokens, API keys, OTP codes, signatures) must be constant-time: `hmac.compare_digest`, `crypto.timingSafeEqual`, `subtle.ConstantTimeCompare`, `MessageDigest.isEqual`. - `==`/`memcmp`/`String.equals` short-circuit on first mismatch → a timing side channel. Treat it as a defect wherever an attacker can submit candidates, but state the claim at the strength the evidence supports: byte-by-byte recovery is the *worst case*, and whether it is reachable depends on the protocol, network noise, attacker position and query volume. The primary sources are careful here and so should you be: Python's `compare_digest` is *"designed to prevent timing analysis by avoiding content-based short circuiting behaviour"* and still notes that *"a timing attack could theoretically reveal information about the types and lengths"* of the operands; libsodium says of `sodium_memcmp` that *"the goal is to mitigate side-channel attacks."* So: fix it unconditionally — the fix is one call — but in a finding, do not promise an exploit you have not demonstrated (principle 3). - Don't branch on secret data or index arrays by secret values in hot crypto paths; in app code, the rule reduces to: use the library comparator, and compare hashes of variable-length secrets to avoid length leaks. ```python # BAD if token == stored: ... # GOOD if hmac.compare_digest(hashlib.sha256(token.encode()).digest(), hashlib.sha256(stored.encode()).digest()): ... ``` ### 6.1 Constant time is a property of the emitted code, not of the source The compiler decides whether your fix survives. That is already the accepted rule for *wiping* — plain `memset` is dead-store-eliminated, which is why `explicit_bzero` / `sodium_memzero` / `SecureZeroMemory` exist (`sota-c-cpp` rules/04 §4) — and the same reasoning governs every other constant-time construct, where it is far less widely applied: - **Secret-dependent `/` and `%` lower to a variable-latency instruction** (x86-64 `IDIV`, arm64 `SDIV`) whose timing depends on the operands. The KyberSlash class is exactly this, and no amount of source-level care removes it. - **"I made the divisor a constant so it strength-reduces" is a hope, not a fix.** Whether the optimiser turns a constant division into a multiply-shift varies by compiler, target *and* optimisation level. Field-reported: one such fix still emitted a real divide at **every** level on one target, and at `-Os`/`-Oz` on two others — and `-Os`/`-Oz` are levels shipped binaries commonly use. - **So read the disassembly, across the matrix you actually ship** — each target architecture and each optimisation level, built with the toolchain that builds your product rather than whichever cross-compiler was convenient. **A clean result proves one configuration constant-time, never the code.** - If you hand-write the multiply-shift, **check it against the original expression over the whole input domain**, not over samples: an off-by-a-power-of-two reciprocal agrees for millions of inputs before it diverges — the exhaustive-domain case in `sota-testing` rules/06. - Static inspection of emitted code and **statistical timing measurement of the running binary are two different instruments** answering two different questions, and neither sees cache or other microarchitectural channels. Say which one you ran, and do not let one stand in for the other (`rules/15` §2). ## 7. Signing & signed artifacts - Signed URLs / signed cookies / license blobs: HMAC-SHA-256 over a **canonical, unambiguous encoding** of all security-relevant fields (object, verb, expiry, principal) — concatenation without delimiters is forgeable (`user=ab` + `role=c` vs `user=a` + `brole=c`); use length-prefixed or serialized-struct encoding. Always include and verify expiry. - Verify-then-parse: check the signature before interpreting any field (signature covers everything you act on, including the key version). - Ed25519 for third-party-verifiable signatures (webhooks you emit, release artifacts, inter-service assertions); HMAC when signer and verifier are the same trust domain. Publish/rotate verification keys via versioned key sets (JWKS pattern), never "the current key" with no ID. - For software supply chain: sign releases/containers (Sigstore/cosign-class tooling), verify in deploy pipelines; lockfile + checksum verification for dependencies is the minimum (CWE-494, A08:2025; supply chain is now its own OWASP category, A03:2025). ## 8. Tamper-evident logs & audit ledgers (NIST AU-9/AU-10) Applies to anything claiming "tamper-evident", "audit-grade", or "immutable" records: hash-chained audit logs, compliance trails (EU AI Act Art. 12, FINRA 4511 WORM), agent/action ledgers, signed receipts. - An **unkeyed hash chain** (each record carries the previous record's SHA-256) detects accidental corruption and naive edits only — an attacker with write access to the store recomputes every hash and forges a clean chain. Integrity against an adversary needs a key or an anchor: HMAC/sign entries with a key held outside the store's trust domain, and/or periodically **anchor the chain head externally** (signed checkpoints, RFC 3161 timestamps, forward to WORM/SIEM, transparency-log publish). - **Tail truncation is invisible** to chain-walk verification — deleting the newest N records leaves a perfectly valid chain; so does deleting an entire chain/stream. Detection needs an externally recorded head (hash + count), signed close markers, or an out-of-store registry of chains. - **A partitioned chain must chain its partitions.** Ledgers get segmented for ordinary operational reasons — fixed-size epochs to bound an index, daily partitions, rotated files, a re-shard — and the obvious implementation starts each segment fresh with `prev_hash = NULL`. Deleting an entire *interior* segment is then undetectable: both sides of the hole verify clean and the new first record looks like a legitimate start. Carry the previous segment's head hash into the first record of the next, and have the verifier carry its running hash **across** the boundary instead of resetting at it — the defect is usually in the verifier, not the writer, which makes it a `rules/15` §3 guard-shaped bug rather than a crypto bug. Deletion has three geometries and a chain walk covers only two: interior record (caught by the `prev_hash` mismatch), interior segment (caught **only** with boundary continuity), tail or whole stream (never caught by a walk — needs the external head above). - Hash **every field you attest** — including server-assigned timestamps and anything used to order or attribute records. Unhashed "projection" columns can be rewritten without breaking the chain. - The preimage must be a canonical, unambiguous encoding (§7) — and "canonical" has to name a **spec**, not an intention: RFC 8785 (JSON Canonicalization Scheme, June 2020, Informational) or a written encoder with its key sort, number format, string escaping and null/absent handling pinned. It fails in two directions and most guidance states only the first. **Forgery:** delimiter-joined concatenation of attacker-influenced fields is breakable by field-boundary shifting. **False alarm:** a language's default map/JSON encoder is not a canonical encoder — Go's spec says "the iteration order over maps is not specified and is not guaranteed to be the same from one iteration to the next", Elixir's `Map` documentation says "key-value pairs in a map do not follow any order", and an implementation may change that order with size as the map switches internal representation; float formatting, unicode escaping and implicit numeric→string conversion vary the same way. Identical data then hashes to different bytes, and the ledger reports tamper on records nobody touched. That is the more dangerous direction operationally: an integrity alarm that is wrong on ordinary traffic gets muted or ignored, and a muted alarm is an inert control (rules/10). Pin the encoding with a **known-answer test vector** committed as a fixture and reproduced by every implementation — without one, the off-system verifier required below is just a second implementation free to disagree with the first, and `chain broken` will not say which of them is wrong. - **Integrity ≠ completeness.** A chain proves what was *delivered*, not what *happened*: records dropped before ingestion (client buffers, "never raise" SDKs, server-assigned sequence numbers) leave no gap. If completeness is a claim, attest it separately (source-assigned sequence numbers, declared counts, close markers) and report the two verdicts separately. **A TEE does not fix this** — a common and expensive wrong turn. Confidential computing protects a record's confidentiality and integrity *once it exists*; no hardware can compel a component to emit one. "Never recorded" is a **liveness** failure, and liveness sits explicitly outside the CC guarantee: the host may refuse to schedule, pause, or destroy the enclave at will (`sota-confidential-computing` rules/01 §2, availability row; rules/04 §7 states that any design assuming a TEE guarantees liveness is wrong). The fix is the separate completeness attestation above, taken at a vantage the monitored component does not control — see the vantage bullet below. - Verification must be possible **off the system that stores the data** (standalone verifier + a documented, cross-language canonicalization spec). A `verify` that only runs on the server being audited proves little. - Vantage matters: a record emitted voluntarily by the monitored component can be omitted at will — for security evidence prefer an independent chokepoint (proxy, gateway, kernel, append-only sink). See sota-detection-engineering rules/02 (telemetry integrity). - Immutable/append-only stores holding personal data need erasure designed up front — per-subject crypto-shredding (sota-privacy-compliance rules/03 §4); retrofitting deletion breaks either the chain or the law. ## 9. Secrets hygiene in code & pipelines - Secret detection in CI (gitleaks/trufflehog) blocking merges; pre-commit hooks as the early net. On any hit: **rotate first**, then scrub history. - Secrets never in: URLs/query strings (CWE-598), log statements (rules/07), error messages, client-visible config (`NEXT_PUBLIC_*`, mobile binaries — anything shipped to the client is public), CI logs (mask + use OIDC-federated short-lived cloud creds instead of static keys). - Distinguish secret classes: long-lived signing keys (KMS, non-exportable) vs rotating service credentials (secrets manager, TTL) vs per-user tokens (hashed at rest). - Crypto agility: central crypto module/wrapper so algorithm/params live in one place; grep-able, upgradeable, with ciphertext version tags. ## 10. Audit grep starters High-signal patterns to sweep for in AUDIT mode (confirm reachability before reporting — see SKILL.md): ```text verify=False | InsecureSkipVerify | rejectUnauthorized:\s*false | TrustAllCerts NoopHostnameVerifier | CURLOPT_SSL_VERIFYPEER,\s*0 | ssl._create_unverified MD5|SHA1 near sign/verify/token/password AES/ECB | DES | RC4 | Blowfish Math\.random|random\.random|java\.util\.Random near token/key/secret/otp/nonce new IvParameterSpec\(.*getBytes (static IV) "-----BEGIN (RSA|EC|) PRIVATE KEY" == or equals\( comparing signature|mac|token|otp secret\s*=\s*["'][A-Za-z0-9+/]{8,} createCipheriv\(.*, *(['"]).{1,16}\1 (short/static key/nonce) ``` ## Audit checklist - [ ] **Was every constant-time claim checked in the emitted code (§6.1)**, across the architectures and optimisation levels actually shipped — including `-Os`/`-Oz` — rather than read off the source? Any secret-dependent `/` or `%` located, and a hand-written multiply-shift replacement verified over the whole input domain rather than samples? - [ ] Are all symmetric encryptions AEAD (GCM/ChaCha20-Poly1305 family), with no ECB/unauthenticated-CBC/custom modes anywhere? - [ ] Is nonce generation per-key safe (counter or XChaCha/SIV for random), never hardcoded or derived from predictable values? - [ ] Is AAD used to bind ciphertexts to their context? - [ ] Do all security tokens/keys come from the OS CSPRNG with ≥128-bit entropy? - [ ] Are there zero hardcoded secrets in source, git history, or client bundles, with CI secret scanning enforced? - [ ] Are keys separated per purpose/environment, versioned, and rotatable without downtime? - [ ] Is every TLS client verifying certificates and hostnames (no skip-verify flags), TLS ≥1.2 AEAD-only? - [ ] Are all secret comparisons (tokens, MACs, OTPs) constant-time? - [ ] Are long-lived stored tokens hashed at rest? - [ ] Is MD5/SHA-1 absent from any security-relevant use? - [ ] Do decryption/verification failures return uniform errors and stop processing before plaintext use? - [ ] Are signed URLs/blobs HMAC'd over canonical encodings with expiry, verified before any field is used? - [ ] Is sensitive-field encryption application-layer (envelope, AAD-bound), with deterministic encryption confined to blind indexes? - [ ] Are dependencies and release artifacts checksum/signature-verified in CI/CD? - [ ] Is there a single crypto wrapper module rather than scattered primitive calls? - [ ] Is any "tamper-evident"/audit ledger keyed (HMAC/signature) or externally anchored — not a bare unkeyed hash chain — with tail truncation and whole-stream deletion detectable? - [ ] Is ledger completeness attested separately from integrity (source-assigned seq / close markers), and is verification possible off the storing system? - [ ] If the ledger is segmented (epochs, daily partitions, rotated files), does the first record of each segment chain to the previous segment's head **and** the verifier carry its running hash across the boundary — demonstrated against a fixture with one whole interior segment removed? - [ ] Is the hash preimage a **named** canonicalization (RFC 8785 or a written encoder spec) rather than a default JSON/map serializer, pinned by a committed known-answer vector that every verifier implementation reproduces byte-for-byte? - [ ] Is a TEE/confidential-computing control proposed to fix a **completeness** gap ("records that were never emitted")? That is a liveness failure and sits outside the CC guarantee — the fix is a separate completeness attestation at a vantage the monitored component does not control. -
05-web-security.md 14.1 KB
# 05 — Web Platform Security Scope: XSS, CSP, CSRF, CORS, clickjacking, security headers, cookies, file uploads. Maps to OWASP A05/A02/A07:2025, CWE-79/352/942/1021/434/1004. Core principle: the browser enforces your security policy — but only the policy you actually declare. Output encoding, headers, cookie attributes, and CORS are **declarative contracts**; an unset header is a vulnerability you chose by default. ## 1. XSS — context-aware output encoding (CWE-79) - Encoding must match the **output context**; one HTML-escape pass is not enough: - HTML body → HTML-entity encode (`&<>"'`). - HTML attribute → quote the attribute AND entity-encode; unquoted attributes are injectable via whitespace. - JavaScript context → don't put data in script blocks; pass via `<script type="application/json" id="data">` + `JSON.parse`, or data attributes. If unavoidable: JSON-encode with `<`, `>`, `&`, U+2028/2029 escaped. - URL context → `encodeURIComponent` for components AND validate scheme — `javascript:`/`data:` URLs survive entity encoding (allowlist `https?:`/relative). - CSS context → don't interpolate untrusted data into styles at all. - Use your framework's auto-escaping templates and audit every bypass: `dangerouslySetInnerHTML`, `v-html`, `innerHTML`/`outerHTML`/ `insertAdjacentHTML`, `bypassSecurityTrustHtml`, Jinja `|safe`, `{!! !!}`, `html/template` → `template.HTML(...)` casts. Each one needs sanitization or removal. - DOM XSS: sources (`location.*`, `document.referrer`, `postMessage` data, `window.name`) flowing to sinks (`innerHTML`, `eval`, `setTimeout(string)`, `document.write`, `location.href=`). Use Trusted Types (`require-trusted-types-for 'script'`) to make sink misuse fail loudly. - Sanitizing rich HTML (user-authored content): DOMPurify (or server-side equivalent) with an explicit tag/attribute allowlist; never regex-strip tags. - `postMessage`: always verify `event.origin` against an allowlist on receive, and set explicit `targetOrigin` (never `*`) on send when payload is sensitive. ```jsx // BAD <div dangerouslySetInnerHTML={{__html: user.bio}} /> // GOOD <div>{user.bio}</div> // framework escapes <div dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(user.bio)}} /> // rich text only ``` ## 2. Content Security Policy - CSP is the XSS backstop, not the fix. SOTA policy is **nonce- or hash-based, with strict-dynamic** — allowlist-of-domains CSPs are routinely bypassed via JSONP/open redirects on allowed CDNs: ``` Content-Security-Policy: default-src 'self'; script-src 'nonce-{random-per-response}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests ``` - Nonce: CSPRNG, per **response** (never static/cached — a cached nonce is no nonce). No `unsafe-inline`/`unsafe-eval` in script-src; if a dependency demands them, that's a dependency finding. - `base-uri 'none'` (blocks `<base>` hijack of relative scripts), `object-src 'none'`, `form-action` (limits credential-phishing form posts even post-XSS). - Roll out with `Content-Security-Policy-Report-Only` + `report-to` first; then enforce. A report-only policy left in place for a year is a finding. ## 3. CSRF (CWE-352) - Defense stack (use the first two together): 1. **SameSite=Lax** (or Strict) on session cookies — default in modern browsers, set it explicitly anyway. 2. **Anti-CSRF token** (synchronizer pattern via framework, or signed double-submit) on every state-changing request. SameSite alone fails for: subdomain-hosted attacker pages, OAuth/POST flows needing `None`, and old clients. 3. Verify `Origin`/`Sec-Fetch-Site` header as cheap defense in depth (`Sec-Fetch-Site: cross-site` on a state change → reject). - State changes only via POST/PUT/PATCH/DELETE — a state-changing GET bypasses every CSRF defense (CWE-352 + CWE-650). - CSRF applies to cookie-authenticated APIs even "JSON-only" ones: verify Content-Type server-side, but don't rely on it alone (form-based `text/plain` smuggling, Flash-era lessons). Bearer-token-in-header APIs are inherently CSRF-immune — one reason to prefer them for SPAs. - Login CSRF is real (attacker logs victim into attacker's account to harvest data): protect the login form too. ```python # GOOD: layered CSRF check (framework token + fetch-metadata backstop) @app.before_request def csrf_guard(): if request.method in ("GET", "HEAD", "OPTIONS"): return # and GETs never mutate state if request.headers.get("Sec-Fetch-Site") not in (None, "same-origin", "same-site"): abort(403) # cheap, header is browser-enforced validate_csrf_token(request) # framework synchronizer token ``` ```text # Signed double-submit (when server-side token storage is impractical): cookie: __Host-csrf = HMAC(key, session_id) ‖ session_id-binding request: X-CSRF-Token header must equal the cookie value, verified server-side # naive double-submit (random cookie == param, unsigned, unbound) is bypassable # via cookie injection from subdomains/MITM — bind to the session and sign. ``` ## 4. CORS (CWE-942) - CORS **relaxes** the same-origin policy; it never adds protection. Misconfig checklist: - `Access-Control-Allow-Origin: *` with `Allow-Credentials: true` — invalid combo, but reflecting the request `Origin` header to simulate it is the classic critical: any site reads authenticated responses. - Origin validation by substring/regex: `origin.includes("trusted.com")` matches `evil-trusted.com.attacker.io`. **Exact-match against an allowlist**, scheme included (`https://app.example.com`). - `null` origin allowed (sandboxed iframes/file:// can send it) — never allowlist `null`. - Keep `Allow-Methods`/`Allow-Headers` minimal; don't blanket-allow `*` on a credentialed API. Cache poisoning: include `Vary: Origin`. - CORS preflights don't protect WebSockets — validate `Origin` on the WS handshake yourself (cross-site WebSocket hijacking). - **XSSI (cross-site script inclusion, CWE-829):** a foreign page can `<script src>`-include a GET endpoint that returns sensitive data as JS/JSONP/array literal and read it. Serve data as `application/json` (never executable JS), require auth + anti-CSRF on data endpoints, don't expose JSONP, and where the legacy risk exists prefix JSON with an unparseable guard (`)]}',\n`). `nosniff` (§6) backs this up. ## 5. Clickjacking & framing (CWE-1021) - `frame-ancestors 'none'` in CSP (authoritative) plus `X-Frame-Options: DENY` for legacy. If embedding is a feature, allowlist exact embedding origins via `frame-ancestors`. - For OAuth consent screens, payment confirmations, and account-change pages, framing protection is mandatory, not optional. - **Double-clickjacking** (2024-class) bypasses `frame-ancestors`/XFO entirely — it uses a timed window swap during a double-click rather than a persistent frame, so framing headers never fire. Defend sensitive one-click actions (OAuth "Authorize", account/payment/permission changes) with an interaction-gated confirmation step (re-auth, an explicit second action, or a short delay before the control is live), not framing headers alone. Disabling unintended same-window opener access (`SameSite` cookies, `noopener`) helps. ## 6. Security headers (baseline set) ``` Strict-Transport-Security: max-age=63072000; includeSubDomains; preload Content-Security-Policy: (see §2) X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() Cross-Origin-Opener-Policy: same-origin Cross-Origin-Resource-Policy: same-origin (relax deliberately per-resource) Cache-Control: no-store (on authenticated/personal responses) ``` - HSTS without `includeSubDomains` leaves cookie-injection via insecure subdomains; preload only when all subdomains are HTTPS-ready. - `nosniff` is what makes your upload Content-Type discipline (§10) stick. - Remove fingerprint headers (`Server`, `X-Powered-By`) — low value but free. - COOP/COEP additionally gate cross-origin isolation (Spectre-class leaks) for apps using SharedArrayBuffer. ## 7. Cookies (CWE-1004/614/565) - Session/auth cookies: `__Host-` prefix + `Secure` + `HttpOnly` + `SameSite=Lax|Strict` + `Path=/`, no `Domain` attribute. The `__Host-` prefix makes the browser enforce Secure/no-Domain/Path=/ — subdomain takeover can't plant or override the cookie. - `HttpOnly` on anything a script doesn't need; CSRF tokens are the usual legitimate non-HttpOnly exception (double-submit reads). - Broad `Domain=.example.com` cookies are readable/settable by every subdomain — one XSS'd or taken-over subdomain compromises all (CWE-565 trust issues). Scope to the host unless sharing is a designed requirement. - Never store authorization-relevant state client-side unsigned (e.g. `is_admin=1` cookie); signed cookies must also be encrypted if contents are sensitive, and validated server-side per request. - Size/count discipline: cookies ride every request — keep tokens, not data. ## 8. Third-party scripts & embeds in the browser - Every third-party `<script src>` runs with your origin's full authority — analytics/tag-manager compromise = Magecart. Minimize; self-host pinned copies where possible; **Subresource Integrity** (`integrity=sha384-...`, `crossorigin=anonymous`) for anything static from a CDN; nonce-based CSP (§2) limits what an injected/compromised script can load next. - Tag managers are remote-code-execution-as-a-service for marketing — gate container changes with review, exclude payment/auth pages from them entirely (also a PCI DSS 4.0.1 §6.4.3/11.6.1 requirement, mandatory since March 2025: script inventory + integrity monitoring on payment pages). - Embedding untrusted content: `<iframe sandbox>` (no `allow-same-origin` + `allow-scripts` together on same-site content — that nullifies the sandbox), minimal `allow=` permissions; untrusted HTML never via `srcdoc` without sanitization. - OAuth popups/postMessage bridges: see §1 `postMessage` rules; verify opener relationships, use COOP to sever unwanted window handles. ## 9. Caching attacks - **Web cache deception**: `/account.php/style.css` cached by path-suffix rules → attacker fetches victim's cached account page. Only cache responses the origin explicitly marks cacheable; `Cache-Control: no-store, private` on all authenticated/personalized responses; CDN cache keys must match the origin's notion of the resource. - **Cache poisoning**: any request input that affects the response but isn't in the cache key (headers like `X-Forwarded-Host`, `X-Original-URL`, unkeyed query params) lets an attacker poison the shared cache. Don't reflect unkeyed inputs; `Vary` on what you use; strip override headers at the edge (rules/01 §11 Host-header rules apply). - Browser-side: `Cache-Control: no-store` for sensitive pages also defends shared-computer history attacks; pair with `Clear-Site-Data: "*"` on logout for high-sensitivity apps. ## 10. File upload handling (CWE-434) - Validate by **content**, not trust: check magic bytes/parse the file with a real decoder; the client `Content-Type` and filename extension are attacker-controlled. - Allowlist extensions AND served content types. Reject double extensions (`shell.php.jpg`), trailing dots/spaces, NUL tricks; **generate the stored filename yourself** (UUID), keep the original only as metadata (also kills path traversal, rules/01 §4). - Store outside the web root, or in object storage with no execute semantics. Never in a directory where the app server executes code (the classic webshell: upload `x.php` into `/uploads` served by PHP). - Serve with: `Content-Type` you determined, `X-Content-Type-Options: nosniff`, `Content-Disposition: attachment` for anything not explicitly displayable, and ideally from a **separate origin/sandbox domain** (usercontent.example) so HTML/SVG payloads can't script against your app origin. SVG is XSS-capable — sanitize or serve as attachment. - Limits: max size (enforced streaming, before buffering whole body), max files/request, rate limits; image processing in a sandboxed/least-privilege worker (decoder CVEs: ImageTragick lineage) with decompression-bomb caps (pixel-count limit before decode). - Scan where threat model warrants (AV/CDR for shared-file features); strip metadata (EXIF GPS) from re-served images (privacy, rules/07). ## Audit checklist - [ ] Is all output encoded for its exact context, with every auto-escape bypass (`innerHTML`, `|safe`, `dangerouslySetInnerHTML`) justified and sanitized? - [ ] Is rich-text HTML sanitized with an allowlist sanitizer (DOMPurify-class), never regex? - [ ] Is CSP nonce/hash-based with `strict-dynamic`, no `unsafe-inline`/`unsafe-eval`, per-response nonces, and actually enforcing (not report-only)? - [ ] Do all state-changing endpoints require non-GET methods plus CSRF tokens, with SameSite cookies as the second layer? - [ ] Does `postMessage` handling verify `event.origin`, and WS handshakes verify `Origin`? - [ ] Is CORS exact-match allowlisted (no reflection, no `null`, no substring matching), with `Vary: Origin`? - [ ] Are `frame-ancestors`/XFO set, especially on auth and confirmation pages? - [ ] Is the full header baseline present (HSTS w/ includeSubDomains, nosniff, Referrer-Policy, COOP) and `Cache-Control: no-store` on personal data? - [ ] Do session cookies use `__Host-` prefix, Secure, HttpOnly, SameSite, host-scoped? - [ ] Are uploads content-validated, renamed server-side, stored non-executable (ideally separate origin), size-capped pre-buffer, and served with nosniff + attachment disposition? - [ ] Are SVGs sanitized or never served inline from the app origin? - [ ] Do third-party scripts carry SRI or self-hosted pins, with tag managers excluded from auth/payment pages? - [ ] Are authenticated responses `no-store`/`private` with cache keys covering every response-affecting input (no unkeyed header reflection)? - [ ] Are sandboxed iframes used for untrusted embeds without `allow-scripts`+`allow-same-origin` together? -
06-memory-resource-safety.md 12.6 KB
# 06 — Memory & Resource Safety Scope: integer overflow/truncation, bounds discipline, unsafe-code policy, untrusted size/length fields, resource exhaustion, concurrency hazards with security impact. Maps to CWE-190/191/787/125/416/400/770/362. Core principle: **arithmetic on attacker-influenced numbers is a security operation.** Most memory-safety exploits start as an integer bug; most outages start as a missing limit. In memory-safe languages the corruption goes away but the logic, truncation, and exhaustion bugs remain. ## 1. Integer overflow & truncation (CWE-190/191/197) - Treat any length, count, offset, size, index, or money amount derived from input as hostile: it can be huge, zero, negative, or crafted to wrap. - Check **before** the operation, in a form that cannot itself overflow: ```c /* BAD: a+b may wrap before the check */ if (a + b > MAX) reject(); /* GOOD */ if (a > MAX - b) reject(); /* unsigned, b <= MAX */ if (__builtin_add_overflow(a, b, &r)) reject(); /* best: checked intrinsics */ ``` - Multiplication for allocation sizing is the classic heap-overflow setup: `malloc(count * size)` → use `calloc(count, size)` (checks internally) or explicit `count > SIZE_MAX / size` guard (CWE-131). - Signed/unsigned conversion: a negative `int` length becomes a huge `size_t` (CWE-195). Validate signedness/range at the boundary, then use one type (`size_t`/`usize`) consistently. - Truncation: 64→32 bit assignment silently drops high bits — a 4GiB+X length truncates to X and passes small-size checks while the real data is huge. - Language quick reference for input-derived arithmetic: | Language | Default behavior | Use instead | |---|---|---| | C/C++ | UB (signed), wrap (unsigned) | `__builtin_*_overflow`, `std::cmp_*` (C++20), UBSan in CI | | Rust | panic (debug), **wrap (release)** | `checked_*`/`saturating_*`/`try_into()`; `overflow-checks = true` in release profile | | Go | silent wrap | manual guards, `math/bits.Mul64` carry checks | | Java | silent wrap | `Math.addExact/multiplyExact`, `long` before narrowing | | C# | silent wrap | `checked {}` blocks or `/checked` compiler flag | | JS/TS | precision loss > 2^53 | `Number.isSafeInteger` on ingest, `BigInt` for counters | | Python | arbitrary precision | still range-check semantics (negative/huge values) | | SQL | dialect-dependent | constrain columns (`CHECK (qty > 0)`), DECIMAL for money | - Money/quantities: overflow and negative-amount bugs are business-critical (transfer of `-100` credits the attacker). Range-check semantic validity (`0 < amount <= LIMIT`), use decimal/integer-cents types, never floats. ```rust // BAD: wraps silently in release; negative-after-cast passes a < check let total = price as u32 * qty as u32; // GOOD: checked, bounded, one unsigned type end-to-end let qty: u32 = input.qty.try_into().map_err(|_| Invalid)?; if !(1..=MAX_QTY).contains(&qty) { return Err(Invalid); } let total = price.checked_mul(qty).ok_or(Overflow)?; ``` ## 2. Bounds & buffer discipline (CWE-787/125/120) - In C/C++: every read/write through a pointer needs a known, checked bound. Banned-by-policy: `gets`, `strcpy`, `strcat`, `sprintf`, `scanf("%s")`; use `snprintf`, `strlcpy`, or length-explicit APIs — and check *their* return values for truncation. - Off-by-one audit points: `<=` vs `<` against array length, NUL-terminator space (`strlen` excludes it), inclusive ranges, loop bounds derived from decremented unsigned values (`for (size_t i = n-1; i >= 0; ...)` never ends). - Prefer structurally safe containers: `std::span`/`std::array::at`, `std::string`, Rust slices, Go slices — and keep raw-pointer arithmetic inside small, reviewed modules. - Use-after-free/double-free (CWE-416/415): ownership must be explicit (RAII/smart pointers, single owner); null out freed pointers in legacy code; beware iterator/reference invalidation on container mutation, and callbacks that outlive their captures. - Build with the mitigations on (they're table stakes, not fixes): ASLR/PIE, stack protectors, `_FORTIFY_SOURCE=3`, CFI where available; CI runs ASan/UBSan on tests and fuzzers (libFuzzer/AFL++) on every parser of untrusted bytes. - New-code policy (CISA/NSA memory-safety guidance direction): prefer memory-safe languages for new components that parse untrusted input; new C is a decision requiring justification, not a default. When extending C/C++, isolate parsers in least-privilege processes (sandboxing — seccomp, pledge/unveil, AppContainer) so a parser bug is a crash, not a compromise. ```c /* BAD: trusts decoded length twice over (overflow + over-read) */ uint32_t n = read_u32(pkt); char *buf = malloc(n + 1); /* n = 0xFFFFFFFF -> malloc(0) */ memcpy(buf, pkt->data, n); /* over-read + heap overflow */ /* GOOD */ uint32_t n = read_u32(pkt); if (n > MAX_MSG || n > pkt->remaining) return ERR_MALFORMED; char *buf = malloc((size_t)n + 1); if (!buf) return ERR_OOM; memcpy(buf, pkt->data, n); buf[n] = '\0'; ``` ## 3. Unsafe-code policy (Rust `unsafe`, FFI, native modules) - Default: forbid. `#![forbid(unsafe_code)]` in app crates; `unsafe` allowed only in designated low-level crates with: - a `// SAFETY:` comment per block stating the invariants and why they hold; - the **smallest possible scope** wrapped in a safe API whose type signature makes misuse impossible (the safety boundary is the module, not the block); - Miri/ASan coverage in CI and mandatory second-reviewer sign-off. - The same policy applies to FFI surfaces everywhere: JNI, cgo, Python C extensions, Node native addons — memory-unsafe code reachable from safe code inherits the full C threat model. Validate all data crossing the FFI boundary in both directions (lengths, encodings, null-termination). - `unsafe` justified by "performance" without a benchmark is a finding. ## 4. Untrusted size/length fields (CWE-130/805) Binary protocol & file-format parsing is where size fields kill: - **Never allocate or read based on a declared size before sanity-checking it** against: protocol maximums, remaining-bytes-actually-available, and global memory budget. `length = read_u32(); buf = alloc(length)` is a one-line DoS (and with truncation, a heap overflow). - Cross-check redundant fields: header total-size vs sum of section sizes vs actual file size; mismatches → reject, don't "repair". - Offsets are size fields too: `base + offset` must be bounds-checked post-add (overflow-safe, §1) before dereference/seek. - Decompression: enforce output-size caps and **ratio caps** (zip/gzip/zstd bombs, CWE-409); decode images with pixel-count limits before full decode; same for XML entity expansion (rules/01 §6). - Parse with length-aware cursors that return errors on underrun, not raw pointer math; fuzz every such parser. ```rust // GOOD pattern: declared length vs available bytes let len = cur.read_u32()? as usize; if len > MAX_RECORD || len > cur.remaining() { return Err(Malformed); } let body = cur.take(len)?; ``` ## 5. Resource exhaustion (CWE-400/770) Every resource an unauthenticated or cheaply-authenticated request can consume needs a cap. Inventory: memory, CPU, file descriptors, threads, DB connections, disk, queue depth, downstream API quota. - Request limits: max body size (enforced while streaming), max header count/size, max URL length, max multipart parts, max JSON depth/keys (deeply nested JSON is a parser CPU/stack bomb), max GraphQL query depth/complexity and disabled introspection-driven amplification (batching, aliases). - Timeouts everywhere: server read/write/idle timeouts (slowloris), and **every** outbound call (HTTP, DB, DNS, gRPC) gets a deadline — a missing client timeout turns a slow dependency into thread-pool exhaustion. Propagate cancellation (context/AbortSignal) so abandoned requests stop working. - Rate limiting: per-principal (user/API key) primary, per-IP secondary (IPv6: limit per /64); token-bucket at the edge plus per-endpoint costs for expensive operations (search, export, password hashing — argon2id itself is a CPU lever, queue/limit login attempts). - Concurrency caps + bounded queues + load shedding (fail fast with 429/503) beat unbounded buffering; unbounded channels/queues just move the OOM. - Amplification asymmetry: reject work where attacker cost ≪ your cost before doing the expensive part (validate-cheap-first ordering; cache negative results; require auth before expensive ops). - Disk: log rotation with caps, temp-file cleanup on all error paths (try/finally), quota per tenant for stored artifacts. - Denial-of-wallet: on serverless/usage-billed infra and metered third-party APIs (LLM tokens, SMS, email), exhaustion shows up as your invoice — hard budget caps + alerts per tenant/feature, and never let an unauthenticated path trigger metered work (SMS-OTP send endpoints are the classic pump). ```go // GOOD: every outbound call carries a deadline; caller cancellation propagates ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() row := db.QueryRowContext(ctx, q, id) // DB req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) // HTTP // server side: http.Server{ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout} // all set — Go's zero values are "no timeout", i.e. slowloris-vulnerable by default ``` ## 6. Concurrency hazards with security impact (CWE-362/367) - TOCTOU on filesystems: check-then-use (`access()` then `open()`) races against symlink swaps — use `open` with `O_NOFOLLOW|O_EXCL` semantics, operate on the fd (`fstat`, `openat`), not the re-resolved path (CWE-367/59). - Race-driven logic bypass: balance checks, coupon redemption, invite acceptance, rate counters — concurrent requests pass the same check before either writes. Fix with DB-level guarantees: atomic conditional updates (`UPDATE ... WHERE balance >= x`), unique constraints, `SELECT ... FOR UPDATE`/serializable transactions, or idempotency keys — never in-process locks across multiple instances. ```sql -- BAD: check in app code, then write (two requests both pass the check) -- SELECT balance FROM accounts WHERE id=$1; ... if balance >= amt: UPDATE ... -- GOOD: the check IS the write; 0 rows affected = insufficient funds UPDATE accounts SET balance = balance - $2 WHERE id = $1 AND balance >= $2; -- GOOD: single-use tokens/coupons via atomic claim UPDATE coupons SET used_by = $1, used_at = now() WHERE code = $2 AND used_by IS NULL; ``` - Shared mutable state across requests (globals, class attributes in pooled workers) leaks one user's data into another's response — keep request state request-scoped; audit caches and reused buffers for cross-request bleed. - Signal/reentrancy handlers and async callbacks touching shared security state need the same discipline. ## 7. Audit grep starters ```text gets\(|strcpy|strcat|sprintf\(|scanf\("%s malloc\(.*\* (unchecked multiply) alloca\( with input-derived arg memcpy\(.*, *len\) trace len's origin \(int\)|\(uint32_t\) casts on size_t/length unsafe \{ without // SAFETY: as u32|as usize on parsed input (Rust) overflow-checks absent in release profile http.Client\{ without Timeout requests.(get|post)\( without timeout= new Worker|Thread\( in request handlers unbounded chan / Queue() / Buffer concat zip|tar|gzip extract without size/ratio cap Image.open/decode without pixel limit os.access\(|fs.exists\( followed by open SELECT.*FOR UPDATE absent near balance/credit math ``` ## Audit checklist - [ ] Is all arithmetic on input-derived sizes/counts/offsets/amounts overflow-checked (checked intrinsics or pre-condition form) before use? - [ ] Are allocation sizes guarded against multiplication overflow and capped against a memory budget? - [ ] Are signed/unsigned conversions and 64→32 truncations on lengths eliminated or explicitly range-checked? - [ ] Do money/quantity fields enforce positive, bounded, integer/decimal semantics? - [ ] Are banned C string functions absent and parsers of untrusted bytes fuzzed with sanitizers in CI? - [ ] Is `unsafe`/FFI code confined to designated modules with SAFETY comments, safe wrappers, and Miri/ASan coverage? - [ ] Does every declared length/offset get validated against bytes-actually-available before allocation or read? - [ ] Are decompression ratio caps, image pixel limits, and JSON/GraphQL depth+complexity limits enforced? - [ ] Do all inbound listeners and outbound calls have timeouts, with cancellation propagation? - [ ] Is rate limiting per-principal with bounded queues and load shedding (no unbounded buffering)? - [ ] Are check-then-act sequences (files, balances, redemptions) made atomic at the storage layer? - [ ] Is request-scoped data verified never to live in shared/global state across requests? -
07-data-exposure.md 13.2 KB
# 07 — Output, Errors, Logging & Data Exposure Scope: error handling without leaks, logging hygiene, mass assignment, verbose APIs and over-exposure, debug surfaces. Maps to OWASP A06/A02/A09:2025 (A09 is "Security Logging and Alerting Failures" since the 2025 release), CWE-209/532/915/213/489/200. Core principle: **exposure is a one-way door.** Injection bugs get patched; a leaked stack trace, token-in-log, or over-fetched PII payload is already in attacker hands, third-party log pipelines, and backups. Design every output — responses, errors, logs, metrics — as if it will be read by an adversary, because logs and error trackers routinely are. ## 1. Error handling without leaks (CWE-209/550) - Two error channels, never mixed: - **To the client**: generic message + stable error code + correlation ID. - **To logs/telemetry**: full exception, stack, context — keyed by the same correlation ID so support can join them. - Never to the client: stack traces, exception class names, SQL fragments, file paths, internal hostnames/IPs, dependency versions, framework debug pages (`DEBUG=True`, Whoops, dev error overlays in prod — CWE-489 adjacent). - Catch at the boundary: a global exception handler that converts *all* unhandled errors to the generic shape; per-route handlers may add precision only from an allowlist of safe messages. - Don't leak via **differences** either: distinct messages, status codes, or response times for "user not found" vs "wrong password", "object missing" vs "forbidden" (use 404 for both, rules/03), padding vs MAC failure (rules/04) — all observable oracles (CWE-203/204). - Fail closed: error paths must not skip authz/validation (`except Exception: return data_anyway`), must release resources, and must not leave partial state (use transactions). ```python # BAD: three different responses = free enumeration + targeting data if not user: return {"error": "No account with that email"}, 404 if user.locked: return {"error": "Account locked"}, 423 if not check(pw, user): return {"error": "Wrong password"}, 401 # GOOD: one response shape; detail goes to the security log, not the attacker ok = user is not None and not user.locked and verify(pw, user) # verify() runs return ({"error": "invalid_credentials"}, 401) if not ok else issue_session(user) # note: run the hash verification even when user is None (dummy hash) — timing ``` ```python # GOOD: boundary handler @app.errorhandler(Exception) def handle(e): cid = new_correlation_id() log.exception("unhandled", extra={"cid": cid}) # full detail, server-side return jsonify(error="internal_error", cid=cid), 500 # generic, client-side ``` ## 2. Logging hygiene (CWE-532) - **Never log**: passwords (including failed attempts — typo'd passwords are near-passwords), session IDs, JWTs/API keys/refresh tokens, full card numbers/CVV, private keys, OTPs, password-reset links, `Authorization`/ `Cookie` headers, full request bodies of auth endpoints. - PII (emails, names, addresses, government IDs, precise geo, health data): log only with purpose; prefer pseudonymous user IDs; mask (`j***@example.com`) or tokenize when the value is needed for support. Retention limits and deletion-on-request must reach logs and backups (GDPR/CCPA exposure is a security finding too). - Enforce structurally, not by memory: - structured logging (JSON) with a **redaction filter** keyed on field names (`password`, `token`, `secret`, `authorization`, `ssn`, ...) and value-shape detectors (JWT regex, PAN Luhn check) at the logger level; - deny-by-default serialization for log objects (log explicit fields, never `log.info(f"{request.__dict__}")` / whole-object dumps); - secrets wrapped in types whose `toString`/`repr` is masked. - **Log injection (CWE-117)**: strip/escape CR/LF and control chars from user-controlled values before logging (forged entries, log-parser exploits — and never let user input reach a log4j-style lookup/format string: log *arguments*, not concatenated format strings; CWE-134). ```python # GOOD: redaction enforced at the logger, not at 500 call sites SECRET_KEYS = re.compile(r"(?i)(pass(word)?|token|secret|authorization|api[_-]?key|cookie|ssn)") JWT_SHAPE = re.compile(r"eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]+") class Redact(logging.Filter): def filter(self, record): if isinstance(record.args, dict): record.args = {k: "[REDACTED]" if SECRET_KEYS.search(k) else v for k, v in record.args.items()} record.msg = JWT_SHAPE.sub("[JWT]", str(record.msg)) record.msg = record.msg.replace("\r", "\\r").replace("\n", "\\n") # CWE-117 return True class Secret(str): def __repr__(self): return "Secret('****')" __str__ = __repr__ # f-string/log interpolation can't leak it ``` - Do log (security observability, OWASP A09): authn successes/failures, authz denials, validation rejections, privilege/role changes, MFA/recovery events, admin actions — with actor, action, target, result, source IP, timestamp; ship to an append-only store with alerting on anomalies. - URLs end up in logs everywhere (proxies, CDNs, browser history): never carry secrets/PII in query strings (CWE-598). ## 3. Mass assignment / over-binding (CWE-915) - Binding request bodies directly to ORM/domain models lets clients set fields you never exposed: `{"role":"admin"}`, `{"email_verified":true}`, `{"tenant_id":...}`, `{"price":0}`. - Fix structurally: **explicit per-endpoint DTOs/schemas** (input models with only the writable fields), then map allowed fields to the entity. Allowlist, never blocklist: ```python # BAD user.update(**request.json) # CWE-915 # GOOD class UpdateProfile(BaseModel): # pydantic: unknown keys rejected model_config = ConfigDict(extra="forbid") display_name: str bio: str user.apply(UpdateProfile(**request.json)) ``` - Framework audit points: Rails `permit!`/broad `permit` lists, Spring `@ModelAttribute` on entities (use `@JsonIgnore`/DTOs), Django `ModelForm` with `fields = "__all__"`, JS `Object.assign(user, req.body)` / `User.update(req.body)`, GraphQL input types mirroring DB models. - Separate create/update/admin schemas — "writable at signup" ≠ "writable forever" (e.g. `email` writable at create, verified-flow-only later). - **Re-derive security-sensitive values server-side; never accept them from the client** (OWASP Business Logic). Prices, subtotals, taxes, totals, balances, discounts, quotas, role/tier — take only identifiers + quantities and recompute from your own store/price book. `{"price": 0}` and `{"items": 5, "total": 0}` are the canonical e-commerce logic exploits; the same applies to credit/quota balances in any multi-tenant or metered system. - Same bug, query side: client-controlled `fields`/`include`/`expand`/`sort` params must resolve through allowlists, or they become column-level IDORs and join-amplification DoS. ## 4. Verbose APIs & over-exposure (CWE-213/200) - **Filter at the source, shape at the edge**: never fetch-everything and rely on the client to ignore fields. Response DTOs are allowlists of what leaves the service; serializing ORM entities directly leaks every added-later column (password hashes, internal flags, soft-deleted rows). ```python # BAD: whatever columns exist (now or after next migration) go over the wire return jsonify(user.__dict__) # or UserSchema(model=User, fields="__all__") # GOOD: output is an explicit allowlist, versioned with the API contract class PublicUser(BaseModel): id: UUID display_name: str avatar_url: HttpUrl | None return PublicUser.model_validate(user) # adding a DB column changes nothing here ``` - Excessive data exposure patterns to hunt: list endpoints returning full objects where the UI shows two fields; `/users/{id}` returning email/phone to any authenticated user; embedded related objects (`order.user.passwordHash`); "admin" fields toggled by serializer flags that default open. - GraphQL: every **field** is an endpoint — apply field-level authz; disable introspection in prod (or gate it); suggestion/typo hints off; cost-limit queries (rules/06 §5). - Enumeration surfaces: incrementing IDs + list endpoints, uniqueness errors ("email taken"), timing differences, sitemap/export endpoints — rate-limit and design responses to avoid existence oracles where it matters. - Metadata leaks: EXIF/GPS in re-served images, document author/revision history in served Office/PDF files, `.git`/`.env`/backup files reachable under the web root, source maps exposing server code paths in prod, verbose `OPTIONS`/`TRACE`. - API versions: deprecated v1 endpoints with weaker checks stay exploitable — decommission, don't just de-document (shadow APIs; keep an inventory). ## 5. Data minimization, retention & secondary stores - Classify data at the schema level (public / internal / confidential / regulated) and let classification drive handling: regulated fields get field-level encryption (rules/04 §4.1), masked logging, restricted serializers, and named retention periods enforced by deletion jobs — not policy documents. - Don't collect what you can't protect: every stored sensitive field is permanent liability; derive (age bracket, not DOB), truncate (last-4), or process-and-discard where the product allows. - Secondary stores inherit exposure but escape controls — audit them explicitly: analytics events, data warehouses/ETL, search indexes, caches, queue payloads (often logged by brokers), crash/error trackers (Sentry-class tools capture local variables — configure scrubbing), session-replay tools (capture keystrokes — block on auth/payment fields), backups (encrypted, access-controlled, retention-bounded, restore-tested). - Deletion must be real: "deleted_at" soft-delete still serves data to any query missing the filter and to every secondary store; account-deletion flows must fan out to logs, backups schedule, search, analytics, and vendors. - Exports/reports are mass-exposure events: same authz as the underlying data (rules/03), watermark/audit who exported what, rate-limit, and expire download links (signed, short-TTL — rules/04 §7). ## 6. Debug & non-prod surfaces (CWE-489) - Production must have: debug modes off (framework debug pages, GraphQL playgrounds, Swagger UIs gated or auth'd), actuator/metrics/health endpoints restricted (`/actuator/env`, `/debug/pprof`, `/metrics` leak secrets/topology), profilers and REPL endpoints absent. - Test/seed accounts, magic bypass headers (`X-Debug-User`), and feature-flag backdoors must never ship — grep for them in audits. - Non-prod environments holding prod data inherit prod's threat model: either mask/synthesize data or secure staging like prod (staging breaches are real breaches). ## 7. Audit grep starters ```text printStackTrace|traceback.format_exc|err.Error\(\) flowing into responses DEBUG\s*=\s*True | app.debug | NODE_ENV !== 'production' branches serving errors log.*(password|token|secret|authorization|cookie|ssn|card) console.log\(req\b logger?\.\w+\(.*\+.*(req|input|user) (format-string / log-injection shape) \*\*request\.(json|form|POST)|Object.assign\(.*req.body|update\(req.body|permit! fields\s*=\s*["']__all__["'] to_json without :only / serializer w/o fields jsonify\(.*__dict__|model_to_dict\( GraphQL introspection enabled in prod config X-Debug|X-Test-User|bypass|backdoor|magic in auth middleware /actuator|/debug/pprof|/metrics routes without auth sourceMap: true in prod build ``` ## Audit checklist - [ ] Does a global boundary handler convert all unhandled errors to generic client messages with correlation IDs, full detail server-side only? - [ ] Are stack traces, paths, SQL, versions, and framework debug pages unreachable in prod responses? - [ ] Are existence/secret oracles avoided (uniform messages, codes, and timing for auth and object-access failures)? - [ ] Is there a logger-level redaction filter for credentials/tokens/PII, plus masked-`repr` secret types? - [ ] Are user-controlled values sanitized for CR/LF before logging, and format strings never built from input? - [ ] Are security events (logins, denials, role changes, admin actions) logged with actor/action/target to an append-only store with alerting? - [ ] Are query strings free of secrets and PII? - [ ] Does every write endpoint bind through an explicit allowlist DTO (`extra="forbid"`) — no direct body-to-model assignment? - [ ] Are privileged fields (role, verified, tenant_id, price) unwritable via any public schema? - [ ] Do responses use explicit output DTOs (no raw entity serialization), with field-level authz on GraphQL? - [ ] Are client-controlled field/include/expand/sort params allowlist-resolved? - [ ] Are debug endpoints, playgrounds, actuators, source maps, `.git`/`.env`, and magic test bypasses absent from prod, and staging data masked or staging prod-hardened? - [ ] Are error trackers, session replay, analytics, warehouses, caches, and backups covered by the same scrubbing/retention/access rules as the primary DB? - [ ] Do deletion flows reach secondary stores, and do exports carry full authz, audit, and short-TTL signed links? - [ ] Is data classified at the schema level with retention enforced by automated deletion jobs? -
08-llm-ai-security.md 20.3 KB
# 08 — LLM & AI Application Security Scope: prompt injection boundaries, tool-call authorization, model-output handling, RAG/data-plane risks, agent loop containment. Maps to OWASP LLM Top 10 2025 (LLM01 Prompt Injection, LLM05 Improper Output Handling, LLM06 Excessive Agency, LLM08 Vector/Embedding Weaknesses) and the OWASP Top 10 for Agentic Applications 2026 (ASI01 Agent Goal Hijack, ASI02 Tool Misuse, ASI05 Unexpected Code Execution, ASI06 Memory & Context Poisoning, ASI07 Insecure Inter-Agent Communication), CWE-77/94/441/863 analogues. Use both lists when auditing tool-using agents; the agentic list's core principle — **least-agency**: grant the minimum autonomy the task needs — is this file's §1–2 in one word. Core principle: **the model is an untrusted interpreter that executes natural language.** Anything that reaches the context window — user messages, retrieved documents, web pages, tool results, file contents — is potential instruction. There is no reliable in-band defense; security comes from *out-of-band* architecture: what the model is allowed to do, see, and emit is enforced by code around it, never by the prompt itself. ## 1. Prompt injection boundaries (LLM01) - Assume injection succeeds. Design question: "when (not if) the model obeys attacker text, what can it actually do?" Bound that blast radius first. - **Direct injection** (user typing "ignore previous instructions") matters mostly when the prompt guards something — never put secrets, hidden business rules, or authorization decisions in the system prompt; assume full prompt disclosure (LLM07). - **Indirect injection** is the serious one: instructions embedded in content the model processes — web pages, emails, PDFs, code comments, calendar invites, RAG chunks, prior tool output. Any pipeline where the model reads third-party content and can then *act* (tools) or *render* (output to user) is the attack path. - Structural mitigations (stack them; none is sufficient alone): - **Privilege separation by context**: untrusted content goes in delimited data sections with explicit "this is data, not instructions" framing, and — stronger — separate model calls: a quarantined call summarizes/extracts from untrusted content with **no tools**, returning structured data; only the trusted-context call gets tool access (dual-LLM pattern). - **Capability gating on taint**: once a session/agent has ingested untrusted content, downgrade what it may do (e.g. can no longer call send_email/exfiltrate-capable tools) — taint tracking at the orchestrator. - Prompt-injection classifiers/heuristics as telemetry and friction, not as the security boundary. - **A same-class checker is not an independent layer.** A classifier, judge, or "second opinion" tier drawn from the same model family as the system it guards shares that system's blind spots *by construction*: the inputs that slip past the primary are disproportionately the ones the checker also reads as benign. This is **common-cause failure** — two components that fail together do not multiply into defence in depth, however the diagram is drawn. **Escalate-only cascades are strictly worse**, and deductively so: a tier that only sees inputs the primary scored *uncertain* cannot see an input the primary scored confidently — and a confidently-wrong score is precisely the failure you needed caught. Its marginal recall on the hard class is bounded by the primary's uncertainty coverage, not by its own accuracy, so a better second model does not fix it. Therefore: **do not count such a tier as a layer in a threat model** until you have measured its marginal recall *on the hard class specifically* — the inputs the primary gets wrong — rather than on a mixed corpus where easy cases dominate the mean. A layer that adds nothing on the class you care about is a control that looks enabled and does nothing (rules/10 §1). The same reasoning applies to any guard sharing a substrate with the guarded system: the same model family, the same tokenizer, the same training corpus, or the same normalization step that produced the miss. - The lethal trifecta to refuse by design: (a) access to private data + (b) exposure to untrusted content + (c) an exfiltration channel (tool that sends data out, markdown image rendering, link generation). Any agent with all three is exploitable; remove or gate one leg. ```python # GOOD: orchestrator-level taint gating (illustrative) class Session: tainted: bool = False # set True when untrusted content enters context def ingest(session, content, source): if source.trust != "first_party": session.tainted = True session.context.append(wrap_as_data(content, source)) # delimited, labeled def allowed_tools(session): if session.tainted: return [t for t in session.tools if t.read_only and not t.exfil_capable] # no send_email, no fetch_url return session.tools ``` - **Memory/persistence poisoning**: long-term agent memory, scratchpads, and "learned preferences" written while processing untrusted content become persistent injections replayed into every future session. Gate memory writes (human-visible, schema-constrained, provenance-tagged), and make memory user-scoped — one user's poisoned memory must never reach another's session. - Multi-agent systems: each hop is a trust boundary. Agent B must not treat agent A's output as instructions-with-A's-privileges; propagate taint and the original human principal through the whole chain (rules/03 §6 deputy rules apply between agents). ## 2. Tool-call authorization (LLM06 — excessive agency) - **Authorization is enforced by the tool layer, never by the prompt.** "Only call delete_user for admins" in a system prompt is not a control. The tool executor checks the *human principal's* permissions on every invocation — the model's request is an unauthenticated suggestion (confused deputy, rules/03 §6: the agent is the deputy). - Run tools with the **user's identity and scopes**, not a god-mode service account: pass the user's token/context through; an agent serving user A must be physically unable to read user B's data (tenant scoping at the data layer, rules/03 §5). - Least-capability toolset: expose the minimal tools per task; narrow parameters (e.g. `search_orders(customer_id=<bound from session>)` — the model never supplies the customer_id); read-only by default, mutation tools separate and gated. - **Validate tool arguments like any untrusted input** — model output IS untrusted input (rules/01 applies in full): schema-validate, then apply the same SQLi/path traversal/SSRF/command-injection guards as for user input. A `fetch_url` tool needs the complete SSRF defense from rules/01 §5. - Human-in-the-loop for irreversible/high-impact actions (payments, deletes, external sends, code execution): explicit confirmation showing the *actual parameters*, not the model's summary of them; batch approvals and "always allow" defeat the control — scope them narrowly. - Rate-limit and budget-limit per session: max tool calls, max spend, max loop iterations (agent loops are resource-exhaustion surfaces, rules/06 §5). - Audit-log every tool invocation: principal, session, full arguments, result size, taint state — agent actions must be attributable and reconstructible. ```python # GOOD: executor-side enforcement, model never sees other tenants def execute_tool(call, session): spec = TOOL_REGISTRY[call.name] # unknown tool -> reject args = spec.schema.parse(call.arguments) # strict schema, extra=forbid authorize(session.user, spec.permission) # user's perms, not model's args = spec.bind_session_scope(args, session) # tenant/user ids from session if spec.high_impact: require_user_confirmation(session, spec, args) return spec.run(args, credentials=session.user_creds) ``` ## 3. Output handling (LLM05 — improper output handling) - Model output is **untrusted, attacker-influenceable data**. Every sink needs the corresponding defense: - Rendered in web UI → encode/sanitize like user content (rules/05 §1). Rendering model markdown as HTML without sanitization is stored XSS; **markdown image/link URLs are an exfiltration channel** (``) — proxy or strip external images, allowlist link domains, CSP `img-src` as backstop. - Executed as code (codegen, "run this SQL", eval'd snippets) → treat as RCE by design: sandbox (no network, ephemeral FS, resource caps), review gates for anything persisted; never `eval` model output in the app process (CWE-94). - Used in queries/commands/paths → parameterize/validate exactly as rules/01. - Fed to another model or template → injection chains; keep the data/ instruction separation at every hop. ```python # GOOD: model markdown -> safe HTML (chat UI) html = markdown_to_html(model_output) html = DOMPurify_equivalent.sanitize(html, allow=BASIC_TAGS) # rules/05 §1 html = rewrite_images(html, lambda src: PROXY_URL + sign(src) if allowed_image_host(src) else DROP) # exfil channel html = rewrite_links(html, require_scheme={"https"}, mark_external=True) # plus CSP img-src 'self' proxy.example as the backstop (rules/05 §2) ``` ```python # GOOD: sandbox floor for model-generated code execution run_in_sandbox(code, network="none", # or allowlist of package mirrors at build step fs=ephemeral_overlay(), # nothing persists, no host mounts limits=dict(cpu_s=10, mem_mb=512, pids=64, wallclock_s=30), user="nobody", seccomp=STRICT_PROFILE) # container alone is not a sandbox; gVisor/Firecracker-class isolation for hostile code ``` - Parse structured output strictly: schema-validate JSON tool calls/extractions (`extra=forbid`, types, ranges); on failure reject/retry — don't "best-effort repair" your way into accepting injected structure. - Don't trust model self-reports: "I have verified the user is authorized" or fabricated tool results must have no effect — state lives in the orchestrator, decisions come from code. - Content-safety/PII filters on output where the application demands it — applied post-generation in code, with the same redaction discipline as logs (rules/07 §2): model responses must not echo secrets present in context (keys, other users' data) — best fixed by not putting them in context. ## 4. RAG & data-plane risks (LLM08) - Retrieval is an authorization surface: **enforce the querying user's ACLs at retrieval time** (filter by permitted doc IDs/tenant in the vector store query). Embedding-then-retrieving across tenants is a cross-tenant leak even if the model "promises" not to reveal it. Don't embed content the user population may never see, or partition indexes per tenant. ```python # GOOD: ACL filtering happens in the vector store query, not after retrieval results = vstore.search( embedding=embed(query), filter={"tenant_id": session.tenant_id, # session-derived, rules/03 "doc_id": {"$in": acl.readable_doc_ids(session.user)}}, top_k=8) # BAD: vstore.search(embedding, top_k=8) then "the model will respect permissions" ``` - Poisoning: anyone who can write to ingested sources (wikis, tickets, public web) can plant indirect injections or biased "facts". Provenance-tag chunks, prefer curated sources for action-driving context, and surface citations so humans can verify. - Membership/extraction: embeddings are not anonymization — vectors can be inverted approximately; protect vector stores like the source documents (encryption, access control, no public endpoints). - **Self-hosted vector DBs ship auth-OFF by default** (Qdrant, among others): set an API key / JWT, enable TLS, and bind to an internal-only network — an exposed keyless vector endpoint is full corpus read/write (poisoning + theft). Restrict *write* access to the ingestion pipeline identity only, and hash each ingested document (e.g. SHA-256) so tampered/poisoned chunks are detectable and excludable at retrieval. - Cache keyed on prompts must be principal-scoped (semantic caches returning user A's answer — containing A's data — to user B). ## 5. Platform & supply-chain notes - Treat downloaded models/weights/adapters like executable dependencies: pinned versions, checksums, trusted registries; `pickle`-loaded checkpoints are arbitrary code execution (rules/01 §8) — use safetensors. - System prompts, tool definitions, and guardrail configs are security-relevant code: version them, review changes, test with an adversarial suite (injection corpus + your own red-team cases) in CI; regression-test on every model/prompt upgrade since behavior shifts. ```python # GOOD: adversarial regression tests assert ORCHESTRATOR behavior, not model politeness @pytest.mark.parametrize("payload", load_corpus("injections.jsonl")) def test_indirect_injection_cannot_trigger_tools(agent, payload): doc = make_document(body=payload) # injection inside retrieved content result = agent.run("summarize this document", docs=[doc]) assert result.tool_calls_outside(["search_docs"]) == [] # taint gate held assert no_external_urls(result.rendered_html) # no exfil markup # the assertion is on enforced capability, so it stays green across model upgrades ``` - MCP and third-party tool servers: a tool's *description* is prompt-injectable too (tool poisoning); pin/review tool manifests, prefer allowlisted servers, and apply §2 executor-side authorization regardless of what the server claims. Remote MCP servers must require auth (the MCP spec's OAuth-based authorization, spec rev 2025-11-25) — unauthenticated internet-exposed MCP servers and trojaned MCP packages are recurring 2026 incident patterns (see NSA's CSI "Model Context Protocol (MCP): Security Design Considerations", May 2026). Agent config files the harness executes (hooks, settings, MCP server definitions checked into repos) are a code-execution surface: review them like CI config, never let the agent write them unapproved. - Named MCP/agent attack classes — use these names in findings (IDs: OWASP MCP Top 10 MCP03:2025 Tool Poisoning, with rug pulls and shadowing as sub-techniques; MITRE ATLAS AML.T0104 Publish Poisoned AI Agent Tool): - **Tool poisoning**: malicious instructions hidden in tool descriptions/schemas/metadata that the model reads but UIs truncate. Mitigate: pin + review full tool definitions at install, diff on change, render complete descriptions to the human approver. - **Rug pull**: a tool/server changes its definition or behavior *after* approval. Mitigate: hash/pin tool definitions, force re-approval on any change, version-lock MCP servers like dependencies. - **Tool shadowing**: a malicious server's tool description manipulates how the model uses ANOTHER server's tools (no malicious tool need ever be called). Mitigate: minimize concurrent servers, isolate high-privilege tools in separate sessions/agents, egress controls as backstop. - **Line jumping**: injection via tool metadata at `tools/list` time — the model is influenced before any tool is invoked, so invocation-time gates never fire. Mitigate: treat tool listings as untrusted input; gate the *connection* on description review, not just calls on approval. - **Preference manipulation (MPMA)**: persuasive/manipulative tool descriptions bias the model toward an attacker's server over legitimate ones. Mitigate: allowlisted servers; review descriptions for superlatives and instructions, not just server code. - **Reasoning-model attacks**: CoT hijacking / H-CoT — attacker text mimicking the model's own reasoning, smuggled into context to steer safety/tool decisions; and OverThink-class slowdowns — decoy problems planted in retrieved content force excessive reasoning tokens (cost/latency DoS, unbounded-consumption class). No OWASP/ATLAS IDs assigned yet. Mitigate: never feed untrusted content as reasoning scaffold/thinking context, cap reasoning-token budgets per request, alert on token-consumption anomalies. - Log prompts/completions for forensics, but apply rules/07 hygiene — context windows routinely contain PII and secrets; redact before storage, scope retention. **This rule is about your application logging its own calls. The developer's coding-agent transcript is a separate surface with a separate owner** — `~/.claude/projects/**/*.jsonl` and its equivalents hold whatever the harness loaded, including files it read on its own initiative: `sota-secrets-management` rules/04 §7. ## 6. Audit grep starters ```text f-string/template building prompts from request data with no data/instruction framing tools=|functions=|tool_choice passed where context includes fetched/retrieved content eval\(|exec\(|subprocess|os.system near model output / completion variables dangerouslySetInnerHTML|innerHTML|v-html rendering completion/message content markdown render of model output without sanitize/image-proxy step vector_store.search|similarity_search without tenant/ACL filter argument api_key|system_prompt containing credentials, internal URLs, or authz rules torch.load\(|pickle.load on downloaded checkpoints (want: safetensors) tool handlers reading user_id/tenant_id from model-supplied arguments "ignore previous"/role-play guards in prompts standing in for code-level checks mcpServers|\.mcp\.json|claude_desktop_config entries without version pin or definition hash ``` ## Audit checklist - [ ] Is there any path where third-party content (web, docs, email, RAG, tool results) reaches a model that holds tools or sensitive context — and if so, is it quarantined (no-tool call, taint-gated capabilities)? - [ ] Does the architecture avoid the lethal trifecta (private data + untrusted content + exfiltration channel) per agent, or gate one leg? - [ ] Are there zero secrets, credentials, or authorization rules living only in prompts? - [ ] Is every tool call authorized in code against the human principal's permissions, with session-bound scoping (tenant/user IDs never model-supplied)? - [ ] Are tool arguments schema-validated and passed through the full rules/01 input defenses (SSRF, path, SQL, command)? - [ ] Do irreversible/high-impact actions require human confirmation displaying actual parameters, with per-session call/spend/iteration budgets? - [ ] Is model output sanitized per sink — HTML-encoded/sanitized for UI, external markdown images blocked/proxied, never eval'd in-process, parameterized into queries? - [ ] Is structured model output strictly schema-parsed with rejection (no lenient repair)? - [ ] Does RAG retrieval enforce the caller's document ACLs/tenant in the store query, with provenance on chunks? - [ ] Are prompt/completion logs redacted, and semantic caches principal-scoped? - [ ] Are model artifacts checksum-pinned (safetensors, no pickle) and prompts/tool manifests version-controlled with adversarial regression tests? - [ ] Is every tool invocation audit-logged with principal, arguments, and taint state? - [ ] Are agent memory writes gated, provenance-tagged, and strictly user-scoped (no cross-user persistence)? - [ ] In multi-agent chains, do taint and the human principal propagate across every hop? - [ ] Is model-generated code executed only in network-isolated, resource-capped, ephemeral sandboxes? - [ ] Are MCP tool definitions hash-pinned at approval with re-approval forced on any change (rug pull), and is the *full* description shown to the approver (tool poisoning)? - [ ] Are high-privilege tools isolated from third-party servers in separate sessions/agents (tool shadowing), with tool listings treated as untrusted input before any invocation (line jumping)? - [ ] Are reasoning-token budgets capped per request with consumption-anomaly alerting (OverThink-class), and is untrusted content kept out of reasoning scaffolds (H-CoT/CoT hijacking)? - [ ] Is any classifier/judge/"second opinion" tier counted as a defence layer in the threat model **from the same model family** as the system it guards — without a measured marginal recall on the hard class (the inputs the primary gets wrong)? Common-cause failure; an escalate-only tier is bounded by the primary's uncertainty coverage and cannot see a confidently-wrong score. -
09-untrusted-data-ingestion.md 15.9 KB
# 09 — Untrusted Data Ingestion Scope: safely ingesting attacker-authored external data — threat-intel feeds, scraped content, user uploads, third-party webhooks/APIs, RAG corpora, email, file imports — through parsers into storage and UI. This file is about hostile data *feeds and content*, where the bytes themselves are the weapon: hostile parsers (image/archive/PDF/Office/XML/CSV), resource-exhaustion at the ingest boundary, schema validation, feed provenance, and the render/LLM exits. Maps to OWASP A08:2025 (Software or Data Integrity Failures), A05:2025 (Injection), A06:2025 (Insecure Design). CWE-409, CWE-776, CWE-434, CWE-22, CWE-502, CWE-1333. This is **not** rules/01 (injection at a *sink* — SQL/shell/path/template) nor rules/05 (encoding at the *render* sink). Those fix the moment data meets an interpreter. This file fixes the moment hostile data *enters the system* — the parse and the pipeline before any sink is reached. Core principle: **all externally-sourced data is attacker-controlled — including data from "trusted" partners, paid feeds, and your own collectors.** A partner's breach is your poisoned feed; a scraper ingests whatever the page author wrote; a webhook claims to be from Stripe until you verify it. Establish a taint mark at the ingest boundary and carry provenance forward: who sourced it, was integrity verified, has it been validated. Ingested data is **data forever** — it never silently becomes instructions (HTML when rendered, SQL when queried, a prompt when retrieved). Untrusted in → typed/validated/provenanced out, or quarantined. ## 1. Treat the ingest boundary as a trust boundary - Every collector, webhook handler, upload endpoint, feed poller, email fetcher, and RAG loader is a trust boundary, on par with an HTTP handler. Apply rules/01 validation discipline here, plus the parse-and-resource controls below. - **Provenance/taint from ingest onward.** Tag each record with `source`, `fetched_at`, `integrity_verified: bool`, `validated: bool`. Downstream code must be able to ask "where did this come from and was it checked" — don't merge untrusted records into the trusted core unlabeled. - **"Trusted partner" is not a control.** Authenticate the *transport* (mTLS, signed webhook, API key) but still treat the *payload* as hostile — auth proves who sent it, not that the contents are benign. A compromised partner sends authenticated poison. - Never trust client-declared metadata: `Content-Type`, filename extension, `Content-Length`, charset. Determine these from the bytes, then compare. ```python # BAD: webhook authenticated, payload trusted blindly if hmac_ok(req): db.save(json.loads(req.body)) # unbounded parse, no schema # GOOD: authenticate transport, then treat payload as hostile if not hmac_ok(req): raise Forbidden() raw = read_limited(req.stream, MAX_BODY) # §3 size cap rec = WebhookEventDTO.model_validate_json(raw) # §4 parse-don't-validate store(rec, source="stripe-webhook", integrity_verified=True, validated=True) ``` ## 2. Hostile parsers — sandbox them (the big one) Format parsers are the largest ingest attack surface: complex state machines in C/C++ (image codecs, PDF, archive libs) with a long CVE history, plus algorithmic-complexity bombs that need no memory-safety bug at all. **Parse untrusted formats in a sandboxed subprocess with CPU/mem/wall-time/FD limits and a hard recover — never in-process in a long-running service.** See sota-sandboxing for process/app isolation (seccomp, Landlock, namespaces, cgroups, broker pattern); the parser sandbox must reach **no secrets and no network** (egress controls per sota-network-security). - **Images — decompression / pixel bombs (CWE-409).** A 4 KB PNG can declare 50000×50000 pixels and explode to gigabytes on decode. **Read dimensions via a header-only decode (`DecodeConfig`) and reject before the full `Decode`.** Cap width×height×bytes-per-pixel against a memory budget. Strip/re-encode through a hardened path; never feed raw uploads to image libs in-process. ```go // BAD: decode first, OOM second img, _, err := image.Decode(r) // GOOD: header-only dimension check, then bounded decode cfg, _, err := image.DecodeConfig(io.LimitReader(r, 64<<10)) if err != nil { return err } if cfg.Width*cfg.Height > 24_000_000 { return ErrTooLarge } // ~24MP cap img, _, err := image.Decode(io.LimitReader(r, 16<<20)) // bounded body ``` - **Archives — zip/tar slip, zip bombs, nested amplification (CWE-22, CWE-409).** Validate every entry path post-join and reject absolute/`..`/symlink entries (Zip Slip — see rules/01 §4). Independently of count, enforce a **decompressed- size cap and a compression-ratio cap** (e.g. reject >100:1 or >total budget) by metering bytes *as you stream the inflate*, not by trusting the header. Cap entry count and recursion depth — a 42 KB zip-of-zips ("42.zip") expands to petabytes; refuse to recurse into nested archives, or bound depth to 1. ```python # GOOD: meter decompressed bytes during extraction, cap ratio + total total = 0 with zipfile.ZipFile(fp) as z: if len(z.infolist()) > MAX_ENTRIES: raise Reject("too many entries") for info in z.infolist(): dest = safe_join(base, info.filename) # rejects ../ + absolute with z.open(info) as src, open(dest, "wb") as out: while chunk := src.read(64 * 1024): total += len(chunk) if total > MAX_TOTAL: raise Reject("zip bomb") if info.compress_size and total / info.compress_size > 100: raise Reject("ratio bomb") out.write(chunk) ``` - **PDF / Office (CWE-434, RCE surface).** These are containers of scripts, fonts, embedded files, and zipped XML. Office docs (DOCX/XLSX/PPTX) are zipped XML — XXE/XEE applies (rules/01 §6). Disable macro/JS execution; never hand a document to a full renderer in-process. Render/convert in a sandboxed worker; extract only the fields you need; treat embedded objects as new untrusted uploads. - **XML (CWE-776, CWE-611).** Disable DTDs and external entities; cap entity expansion (billion-laughs). Full treatment in rules/01 §6 — applies to every SVG, RSS/Atom feed, SOAP, SAML, and Office part. - **CSV / JSON / feed formats.** Deeply-nested JSON (`[[[[…]]]]`) is a stack/CPU DoS — set a max nesting depth and document/field size cap; reject unbounded arrays before materializing. Use streaming parsers with limits for large feeds. CSV formula injection (CWE-1236) is an *export* concern handled at the render boundary (`rules/01` §11), but neutralize on ingest too if cells round-trip to users. - **Fuzzy-hash / similarity libs on hostile input.** ssdeep/tlsh/imagehash and similar are fed exactly the malware/spam they analyze; malformed input crashes or OOMs them. Run them inside the same parser sandbox, time-bounded, with the crash isolated to the worker — never on the request thread of a shared service. ## 3. Resource & DoS controls at the ingest boundary The cheapest attack on an ingester is volume and amplification. Bound everything. - **Size caps at every layer**: max request body, max field length, max file size, max decompressed size, max entry count. Use a `LimitReader`/bounded reader on the raw stream — never read an attacker-controlled `Content-Length` into a buffer, and never `read()`/`ReadAll` an unbounded body. - **Timeouts and rate/volume limits**: wall-clock timeout per parse; per-source rate and concurrency limits so one feed can't starve others. See sota-async-concurrency for bounded concurrency, backpressure, and task limits. - **Backpressure, not buffering**: when downstream is slow, slow the intake; don't accumulate an unbounded in-memory queue (itself a DoS). - **Dead-letter / quarantine poison records.** A record that fails parse, validation, or a resource cap goes to a quarantine/DLQ with its provenance — it does not crash the pipeline, retry-loop forever, or get silently dropped. - **Idempotent re-ingest.** Key records by a stable source id so replays and retries don't duplicate or corrupt state (cross-ref sota-data-engineering data contracts; webhook ingress hardening is rules/01, idempotent webhook delivery is sota-api-design rules/06 §4). ```go // BAD // GOOD body, _ := io.ReadAll(r.Body) body, err := io.ReadAll(io.LimitReader(r.Body, maxBody)) if err != nil || int64(len(body)) >= maxBody { reject() } ``` ## 4. Schema & content validation at the boundary - **Parse, don't validate.** Deserialize into a typed object (pydantic / serde / zod / a generated struct) at the boundary, with `extra=forbid` / reject-unknown-fields / `deny_unknown_fields`. Unknown fields are an attack signal and a mass-assignment vector (rules/07) — reject, don't ignore. - **Allowlist** values, formats, ranges, enums. **Canonicalize then validate** (rules/01 §1) — normalize unicode/encoding once before checking. - **Strip/flag invisible and deceptive Unicode** on text bound for an LLM context or a UI (RAG-corpus and feed text especially): zero-width characters (U+200B–200D, U+FEFF), bidirectional overrides (U+202A–202E, U+2066–2069 — *Trojan-Source*, CVE-2021-42574), and tag characters (U+E0000–E007F) hide injected instructions a reviewer can't see. Normalize (NFKC), drop the format/control categories you don't expect, and flag homoglyph-heavy strings. This is the ingest-side complement to the prompt-injection boundary (rules/08). - **Determine type from bytes, not declaration.** Sniff the real MIME from magic bytes and reject when it disagrees with the declared/extension type. Beware **polyglot files** (valid GIF *and* valid HTML/JS — "GIFAR"): a file that is two types at once defeats single-type checks and becomes stored XSS when served. Re-encode/normalize through a canonical pipeline so the stored artifact is exactly one known type. - **Content scanning for uploads (CWE-434).** Run AV/malware scanning on uploaded files; store under a server-generated id (never the user filename, rules/01 §4); serve from a separate origin/sandbox domain with `Content-Disposition: attachment` and a correct `Content-Type` (upload pipeline detail in rules/05). - Reject before persistence. Invalid data must never reach storage in a form that a later, less-careful reader will trust. ```python # GOOD: typed boundary, unknown fields rejected, type from bytes class FeedItem(BaseModel): model_config = ConfigDict(extra="forbid") id: str; indicator: IPvAnyAddress; severity: Literal["low","med","high"] item = FeedItem.model_validate(record) # reject-unknown, typed if sniff_mime(blob) not in ALLOWED_MIME: raise Reject # bytes, not declared ``` ## 5. Pipeline trust hygiene - **Per-feed provenance + integrity.** Where a feed offers signatures/checksums (signed STIX/TAXII, detached signatures, content digests), verify them and record the result; an unsigned feed is lower-trust and labeled as such. A poisoned upstream feed is a **supply-chain attack on your data** — the same class as a malicious dependency (cross-ref sota-devsecops for provenance, and sota-data-engineering for data contracts and quality gates). - **Separate ingest/parse from the trusted core (broker pattern).** A small, unprivileged front layer fetches and parses in the sandbox; only typed, validated, provenanced objects cross into the core over a narrow interface. The parser process holds **no secrets, no DB credentials, no network egress** beyond what it strictly needs — apply egress allowlists on collectors per sota-network-security. - **Detection on the ingest path.** Anomalies — sudden volume spikes, ratio-bomb rejects, schema-violation bursts, AV hits — are signals; emit them as events for sota-detection-engineering, don't just drop them. Flag **serialized-payload signatures** on inputs that should be plain data — base64 Python pickle prefixes (`gASV`, `gAJ`, `gAR`), Java serialization magic (`rO0`/`0xACED`), PHP `O:<n>:` object markers: their presence in a feed/field is a deserialization-RCE probe, not legitimate content. - **No lateral trust.** Data validated for one purpose isn't validated for another; re-validate at each new boundary it crosses (a value safe for storage may be unsafe for a shell, a query, or a prompt — see §6). ## 6. The exits — render and LLM boundaries Ingested hostile content is inert in storage; it becomes dangerous at an *exit*. Two exits matter beyond rules/01's sinks: - **Render boundary → stored XSS (CWE-79).** Scraped pages, feed descriptions, uploaded SVGs, webhook payloads displayed in a dashboard are stored-XSS fuel. Encoding/sanitization happens at render time, in the render context — see rules/05 and sota-frontend-design / sota-javascript-typescript. Sanitizing on ingest is brittle (you don't know the future render context); store the raw (taint-tagged) value and encode at output. SVG and HTML feed content in particular must be sanitized or served from an isolated origin. - **LLM boundary → indirect prompt injection.** Any ingested content that reaches a model's context — RAG corpora, scraped pages, emails, tool outputs — is attacker-controlled instructions to the model. This is rules/08's domain (indirect prompt injection, lethal trifecta, taint gating); the ingest pipeline enforces the *provenance tag* that rules/08 uses to gate tool calls. Never let ingested text be treated as a trusted system instruction. ## Audit checklist - [ ] Is every collector/webhook/upload/feed/RAG loader treated as a trust boundary with size cap, timeout, and schema validation? - [ ] Are records tagged with provenance (source, fetched_at, integrity_verified, validated) and is "authenticated transport" not conflated with "trusted payload"? - [ ] Are untrusted formats parsed in a sandboxed subprocess (CPU/mem/time/FD limits, no secrets, no egress) rather than in-process in a long-running service? - [ ] Image decode: header-only dimension check (`DecodeConfig`) and pixel/byte budget *before* full `Decode`? (grep: `image.Decode`, `Image.open`, `imread` without a preceding dimension/`DecodeConfig` check) - [ ] Archive extraction: per-entry path validation (no `..`/absolute/symlink), decompressed-size cap, compression-ratio cap, entry-count cap, bounded nesting depth? (grep: `zipfile`, `tarfile`, `extractall`, `archive/zip` without a metered byte counter) - [ ] PDF/Office parsed in a sandboxed worker with macros/JS disabled, embedded objects treated as new uploads, and zipped-XML parts XXE-hardened? - [ ] XML/SVG/RSS/SAML/Office parsers: DTDs + external entities disabled, entity expansion capped? (rules/01 §6) - [ ] JSON/CSV/feed: max nesting depth, document/field size caps, unbounded arrays rejected, streaming parser for large inputs? - [ ] Fuzzy-hash/similarity libs (ssdeep/tlsh/imagehash) run inside the parser sandbox, time-bounded, crash-isolated? - [ ] Is every raw read bounded by a `LimitReader`/bounded reader? (grep: `io.ReadAll`, `read()` with no limit, `request.body` without size cap, missing `LimitReader`) - [ ] Backpressure + bounded concurrency + per-source rate limits, with a dead-letter/quarantine for poison records and idempotent re-ingest? - [ ] Parse-don't-validate into typed objects with reject-unknown-fields, allowlist values, canonicalize-then-validate? - [ ] Upload type determined from magic bytes (not declared Content-Type/extension), polyglots rejected via re-encode/normalize, AV scan, server-generated storage id, isolated serving origin? - [ ] Feed integrity verified where available (signatures/checksums), poisoned-upstream treated as a supply-chain risk, ingest/parse separated from the trusted core (broker pattern)? - [ ] Ingested content encoded at the *render* boundary (rules/05) not sanitized-on-ingest, and provenance-tagged before reaching an LLM context (rules/08)? - [ ] Ingest anomalies (volume spikes, ratio-bomb/schema-violation bursts, AV hits) emitted as detection events? -
10-silent-control-failure.md 13.6 KB
# Silent Control Failure — controls that look enabled and do nothing A crash is loud and gets fixed. This file is about the opposite failure: a control, feature, or safeguard that **appears active but has no effect** — where a broken system and a working system are indistinguishable from the outside. Green health checks, a passing suite, `ENFORCEMENT: ENABLED` in the startup banner, and zero protection. Applies in both modes. In **BUILD**, every control you add must be built so its absence is observable. In **AUDIT**, this is a distinct pass — the classes below are invisible to source-pattern SAST, because the code is not *wrong*, it is *inert*. **Proving a control here actually works is rules/12** — the mutation probe (replace the body with the permissive no-op and watch what fails), plus the suspicion turned on everything doing the checking: your scorers, gates and benchmarks, and the guard that is an instance of what it guards. **Finding these at codebase scale is rules/11** — the cheap diagnostics (duration vs claimed work, printing every gate's denominator, cross-scale delta), plus five classes that are correctness rather than security: scale-dependent silence, a cache key narrower than the behaviour it gates, a parser generalised from one sample, a producer/consumer seam no schema declares, and a filter whose predicate matches the ambient environment (an absolute path, a hostname) so a collection is correct on one machine and empty on another. Sweep with rules/11 to decide *where* to apply this file. Related: fail-open authorization → rules/03 §"authz bypass patterns"; integer truncation → rules/06; prompt-as-control and the LLM threat model → rules/08 §1–2 (`rules/14` §3 here frames it as a silent-control class); the mutation probe and the instrument/guard pass → rules/15; test vacuity and mutation testing in general → `sota-testing` rules/06 and rules/09; degradation telemetry → `sota-observability` rules/05; build/runtime artifact drift → `sota-devsecops` rules/04. --- ## 1. The falsification question For every control you write or examine, ask: > If this were silently a no-op, would anything I can observe look different? If the answer is **no**, that *is* the finding — whether or not the control is currently broken. Absence of a signal is the bug. A control whose success and whose total failure produce identical logs, identical metrics, and identical responses is unfalsifiable, and unfalsifiable controls decay into no-ops without anyone noticing. **The falsification question does not catch every broken control, and the gap is specific.** It finds controls that are *inert*. It misses controls that are **correctly enforcing the wrong predicate** — there, something observable *does* differ (a refusal, a missing signature, an error), so the first question answers "yes" and the control is still wrong. Field-reported as four instances in a single session, by an author who had just documented the shape: | the check asked | the question that mattered | |---|---| | is `commit.gpgsign` true? | is a signing key configured? | | does the encoder round-trip? | does the writer produce what the verifier expects? | | does the `--sk` flag exist? | is `--sk` *implemented* in this binary? | | is there a TTY? | can a PIN be collected here? | Each substitutes an **observable proxy** for the **actual precondition**. The proxy is easier to test, correct when written, and drifts later — because the proxy and the precondition are configured by different people, in different files, at different times. So ask a second question of every control: > **The proxy question.** Is this the thing I actually depend on, or something that > currently agrees with it? Then: *who can change one without changing the other, and > would I find out?* If the answer to the second is "a different file, a different person, no signal", the predicate is a proxy and it will drift. Test the dependency itself. Where it genuinely cannot be tested directly, **record the substitution in a comment at the site, including the direction each error fails in** — the two directions are rarely symmetric: one instance above blocked legitimate work when strict, and would have spent one of three PIN attempts on a token that blocks at zero when loose. The class entry is `rules/14` §4a. Three follow-ups that make it concrete: - **What would I grep for at 3am** to prove this ran on request X? - **What would break** if I deleted the control's body and returned the permissive value? If the answer is "nothing", nothing is holding it in place. - **Who finds out** — a log line nobody reads is not an observer; an alert, a metric with a threshold, or a failing CI gate is. This question is the organizing principle of the whole file. Every class below is a specific way the answer comes out "no". ## 2. Where silent no-ops hide — moved to `rules/16` The catalogue of sixteen shapes (weak existence checks, degraded optional dependencies, swallowed exceptions, truncation into an inspector, the control that is not in force, a flag that parses, the aggregate that masks a detection) now lives in **[rules/16](16-where-no-ops-hide.md)**, with its section numbers unchanged — ``sota-code-security` rules/16 §2.7` there is ``sota-code-security` rules/16 §2.7` as it always was. This file keeps the **method**; that one is the catalogue the method searches. Split 2026-09-12 at 484 of 500 lines (ROADMAP 55). ## 3. Make degradation loud — one helper, deduped per cause When a control cannot do its job, exactly one mechanism reports it. Scattering ad-hoc `logger.warning` calls produces per-request noise that gets filtered, and filtered warnings are invisible — which returns the system to silent failure. Design: - **One shared helper**, e.g. `control_degraded(control, reason, detail)`, used by every control in the codebase. - **Deduplicate per cause, not per request** — log once per (control, reason) per process or per interval. Per-request warnings get rate-limited away by operators and stop being read. - Emit all three signals, per `sota-observability` rules/05: a rate-limited WARN log, a **gauge** (`control_degraded{control="scanner",reason="model_missing"}`) that stays 1 while degraded, and a span/response attribute so a single request's degradation is traceable. - **Surface it in the health/readiness output** — a component running without its enforcement path is not healthy, and "degraded" must be a distinct state from "ok". - Alert on the gauge being 1 for longer than a deploy: fallbacks are for surviving the night, not for permanent operation. ## 4. Evidence rules for this hunt - **Read the code in full context.** No speculation, no pattern-matching. The whole point of this class is that it looks fine. - Finding format (the canonical `file:line | rule | severity | effort | fix`, with the middle expanded for this class): **what looks enabled | why it is silently a no-op | a concrete failure scenario with specific inputs/state → wrong behavior**. - **If the code logs loudly or raises, it is not silent** — say so and exclude it. Loud failures belong to other rules files. - **Separate "silently broken" from "documented and deliberate"** and state which. A metered, documented fail-open is a design decision to review, not a defect to report as one. - **Say "nothing found" per category** rather than padding with weak findings. An honest empty category is a result. - **A negative claim needs more proof than a positive one.** "There are no swallowed exceptions on the enforcement path" is a far stronger assertion than "here is one at `auth.py:88`" — a narrow search and a true absence look identical from the outside. Before asserting absence: widen the search (synonyms, other languages, generated code, vendored trees), use a **second independent method** (grep *and* AST/call-graph *and* a mutation run), and state the search you actually performed so the reader can judge its reach. That governs the **search**, which is discarded once it has answered. If the conclusion is instead left behind as a durable **guard**, it needs the stronger default in `sota-testing` rules/02 `sota-code-security` rules/16 §2.10: structure in AST, behaviour by execution, regex only where no parser exists. - **Before claiming a fix works**: add the regression test, then **revert the fix and confirm the test fails**. A regression test is not evidence until it has been watched to fail. Report the exact command and the pass/fail counts — "should work" is not evidence (router operating principle 6). - **Check the fixture before concluding the code is broken.** A bad test input looks exactly like a broken detector; a validator rejecting a deliberately malformed test value is working as designed. - For anything that **changes enforcement behavior**, stop and present the decision rather than deciding silently (router operating principle 2). --- ## Audit checklist - [ ] **The proxy question asked of every control's predicate** (§1): is the tested condition the dependency itself, or something that currently agrees with it? Name who can change one without the other. A proxy with no signal on divergence is a finding even while it currently works. - [ ] **Every "it produced something" assertion names the field that carries the detection** (`sota-code-security` rules/16 §2.16): which attribute would be empty if the detector were deleted but its preprocessing left intact? An aggregate over a result mixing derived inputs with findings is a liveness check for the input stage, not a control. - [ ] **Every optional capability a design depends on was invoked once, not read from `--help`** (`sota-code-security` rules/16 §2.15). Build-tag-gated flags parse and stub out; keep the output of the real call. High when the capability is the load-bearing half of a security control. - [ ] Anything that writes **outward** on a schedule (a GitOps write-back controller, a PR bot, a sync job) verified at the **destination** rather than from its own success counters — its log reports the update it decided to make, not the write landing (`sota-kubernetes` rules/04 §7)? - [ ] For each security control in scope: if it were a no-op, would any log, metric, response, or test differ? No → finding, regardless of current correctness. - [ ] Presence/enablement decided by real loaded artifacts (non-zero rule count, required files present), not by `exists()`/`is_dir()`/truthiness? - [ ] No `except ImportError` (or equivalent) silently disabling a control; every optional dependency backing a control present in the **shipped** artifact? - [ ] Does a loader that yields zero rules/policies fail closed and loudly? Do shipped example/reference configs load to a non-empty, safe state? - [ ] Broad `except` on an enforcement path returning the permissive value? Grep: `except Exception`/`catch (...)`/`rescue =>`/`recover()` near authz, verify, validate, scan → each is fail-open, silent, or both. - [ ] Any flag used more broadly than its own definition claims (debug/dev_mode also disabling a security check)? - [ ] Any prose instruction ("do not reveal/surface", "ignore instructions below", authz-in-prompt) standing in for an enforced boundary over data or permissions that live in the same context? Enforce structurally/in code (rules/08 §1–2), not by instruction — `rules/14` §3. - [ ] For each gate, does run history show it has ever **executed** (not all-skipped: an unreachable trigger, path/branch filter, or dead lifecycle event) and ever **rejected** anything? State the sample size — "not in the last N runs" is not "never" — `rules/14` §4. - [ ] Early-return guards on empty/oversized/unparseable input that an attacker can deliberately trigger to skip inspection? - [ ] Any truncation (`[:N]`, byte caps, `LIMIT`) on the path *into* a scan, validation, or signature check — or a cap on a **generator's output** (unset `max_tokens`, `--max-results`) whose fragment is then parsed? - [ ] Config/policy schemas reject unknown keys, and every key in the reference config resolves to a real field of its section (tested structurally)? - [ ] Security/privacy/cost-relevant defaults verified in **both** docs and code, with a test pinning the documented default to the parsed one? - [ ] Numbers in tool output derived from what was actually produced, never printed as literals — **and every verification word** (`verified`, `confirmed`, `reachable`, `tainted`, `sanitized`) plus every severity or confidence field traceable to a line that can fail, matched by claim shape rather than keyword and confirmed by reading (`rules/14` §1)? - [ ] Any control sitting in audit / warn / dry-run / report-only mode carrying an owner and an expiry, rather than having lived there since it shipped (`rules/14` §5)? - [ ] Control smoke tests run against the **built artifact** (image/package/ binary), not only the source checkout? Startup asserts its own required artifacts? - [ ] Mutation probe run on security-critical paths, and the instrument or guard that reported the result validated in turn — the whole of rules/12 and rules/15? - [ ] One shared degraded-control helper, deduped per cause, emitting log + gauge + health state — not per-request warnings? - [ ] Findings state what looks enabled, why it is inert, and a concrete failure scenario; loud failures excluded; deliberate fail-open distinguished from silent bypass? - [ ] Every "nothing found" backed by a widened search and a second independent method, with the search performed stated? -
11-dead-path-diagnostics.md 27.3 KB
# Dead Paths — the diagnostics that expose a system reporting success while doing nothing rules/10 asks, one control at a time, *"if this were a no-op, would anything look different?"* This file is the **hunt**: the cheap signals that surface the family across a whole codebase without reading every line, five classes rules/10 does not cover (they are not security controls at all — they are correctness), and the evidence bar a finding in this family has to clear. Use it in AUDIT as the sweep that decides *where* to apply rules/10, and in BUILD as the set of properties that make a stage falsifiable before you ship it. Related: inert controls (the catalog) → rules/10; **proving a control works, and validating the instrument or guard that reported it → rules/15**; fail-open authz → rules/03; truncation before inspection → rules/16 §2.7; mutation testing and watching a test fail → `sota-testing` rules/06 and rules/09; degradation telemetry → `sota-observability` rules/05; scale and cost → `sota-performance` rules/01; shell/CI exit-code masking → `sota-shell-scripting` rules/01. --- ## 1. The governing observation: "zero" is a legitimate answer A bug that produces a **wrong** answer gets caught, because someone compares it to a right one. A bug that produces **no** answer — where "none" is a valid result — is invisible by construction. So the hunting ground is every place where: failure state == a valid success state `0 results`, `nothing to do`, `all clean`, `no changes`, `exit 0`, an empty list, `None`, `0.0`. **If you cannot distinguish "it ran and found nothing" from "it never ran", that is a finding — whether or not it has fired yet.** This is rules/10 §1 turned outward: there, per control; here, per pipeline stage, gate, job, and query. ## 2. The diagnostics, highest yield first ### 2.1 Duration, not result **The single highest-yield tell, and the only one that needs no code reading.** Compare each step's wall time against the work it claims to have done. A stage reporting "0 findings" in 5 s against a 300k-LOC target did not run. A test job that finishes in 3 s, a migration that returns instantly, a backup that completes suspiciously fast — same signal. ``` # Time every stage, then read the ratio, not the verdict. $ time ./scan.sh corpus/ # claims: full AST scan, 40k files real 0m2.104s # ← 40k files in 2s. It did not scan them. ``` What to record in a finding: the **measured** wall time, the input size, and the order of magnitude the claimed work implies. "Fast" is not evidence; "2.1 s for 40k files, vs 6 min for the same corpus on the previous release" is. Two refinements: - **Constant duration across very different inputs** is the same tell without a baseline: if 100 files and 100k files take the same time, size is not reaching the work. - **Duration that collapses after a change** is a regression signal. A scanner that got 30× faster and found the same number of issues did not get faster. **And the same arithmetic applied to your own reasoning: a rate computed over a window longer than the phenomenon describes the window, not the phenomenon.** `sota-observability` rules/02 §4 says averages hide what matters when you *design* a metric; this is the version that bites when you are *debugging*, and nothing routes you there. Field-reported 2026-09-05: an importer's failures averaged **~4.2/s over an hour**, which was used to argue "steady per-call failure, not an expired-context spin" — and on that basis a correct fix was **retracted**. Per minute: ``` 20:51..21:26 2-3 errors per minute 21:27 14,977 errors in ONE minute (~250/s) ``` The hourly mean was arithmetically true and qualitatively backwards. Before reasoning from a rate, look at the distribution at a resolution **finer than the event you are hypothesising about** — a burst and a trickle produce the same mean. And where the data carries a per-event cost, read that too: each of those failures recorded a duration of **11–12 µs**, the signature of a cancelled context returning before any I/O. **12 µs and 12 s are different mechanisms with identical counts.** ### 2.2 Scope of the check — print the denominator **`0 checked, 0 failed, exit 0` is the signature of this entire family.** Every gate must report *how many items it examined*, and must fail closed when that number is unexpectedly zero. A gate whose file glob, pathspec, or selector drifted keeps printing green forever. This library shipped that exact defect and fixed it (2026-07-30). The gate enumerated skill files via `git ls-files 'skills/*/rules/*.md'`; renaming the `rules/` directory level made the pathspec match nothing: ``` # BEFORE — pathspec mutated to match nothing: [2/10] Every skills/*/rules/*.md ends with an '## Audit checklist' ok [10/10] Every skills/*/rules/*.md is referenced by its own SKILL.md ok PASS: all repository invariants satisfied. # exit 0, examined 0 files # AFTER — the same mutation, once each check reported its denominator: SCOPE EMPTY: examined 0 rules files — pathspec drift? a gate that checks nothing passes silently exit=1 ``` A neighbouring check that recounted the tree did **not** catch it, because the count it recounted (`SKILL.md` files) was unaffected — worth stating as its own lesson: *one gate's green does not cover another gate's scope.* The commonest instance ships inside the toolchains themselves, **and they do not agree with each other** — which is the whole point. Both verified by running them: `go test ./...` over a package with no test files prints `? x [no test files]` and **exits 0**; `pytest` on the same empty scope prints `no tests ran` and **exits 5**, as it does for a file with no test functions and for a `-k` selector that deselects everything (pytest 9.1.1). One fails closed, one fails green. So a test stage whose selector drifts is silently green on one toolchain and loud on the next, and no amount of folklore about "runners exit 0 when they find nothing" tells you which you have. Gate on a floor for tests **actually executed**, never on the exit code, and confirm your own runner's zero-collected behaviour by running it — an exit 5 that CI discards is worth exactly as much as an exit 0. Rule for BUILD: a gate prints `ok (N items)`, and `N == 0` is a failure unless zero is explicitly expected and asserted as such. Rule for AUDIT: for every gate, ask what its denominator was on the last run, and whether anything would say so. **The same arithmetic on the output side: a produced size that lands exactly on its limit is a truncation report.** The cheapest tell in this family, and it needs no cooperation from the producer — compare what came back against the cap that bounded it (`output_tokens == max_tokens`, rows == `LIMIT`, bytes == the buffer) and treat equality as truncated until shown otherwise. Field-reported and reproduced 2026-08-19: a recon call left `max_tokens` unset, inherited a 4096 default, and a 4,843-character JSON fragment reached `json.loads` as a plain string — no exception from the provider, no flag, and a swallowing `except` (rules/16 §2.4) then published it as an empty profile. Corollary: **a parse-error offset is uninterpretable without the document length.** "Failed at char 3,023" argues *against* truncation while you assume 4,096 tokens yield 12–16k characters, and *for* it the moment you learn 3,023 was the last character — so log the size beside the offset. Class and fix: rules/16 §2.7. ### 2.2a The empty comparand — the zero on the other operand §2.2 asks how many items the check **examined**. This asks how many were in the set it **compared against**, and the two fail independently. A differential oracle — a regression snapshot, a golden file, an approval set, a "no new findings vs. baseline" gate — computes its verdict from a *reference*, and when that reference is empty the verdict is a constant: ```python lost = baseline - current # findings we used to produce and no longer do gained = current - baseline return 1 if lost else 0 # non-zero on any regression ``` `lost` is drawn **from the baseline**. With an empty `baseline`, `lost` is empty for every possible `current` — the check returns the pass value on every input, forever, while examining a full, healthy current set and reporting a large, *truthful* denominator. §2.2's remedy does not fire here, because nothing about the scope is wrong. Field-reported 2026-09-05: **4 of 12** committed reference sets were in this state, legitimately — the analysis genuinely finds nothing on those targets, and the files are kept because a *gain* is still informative. That is also why nobody notices: `gained` keeps working when the baseline is empty, so the file still earns its place in the tree while half the oracle is dead. **The rule.** Any differential, regression or equivalence check must assert that its reference set is non-empty **and that it loaded** — a baseline file that failed to parse yields the same empty set through a different door — before its result is readable as a pass, and must fail closed otherwise. Where an empty reference is a legitimate recorded state, require an explicit, greppable opt-out flag: the flag is how a human records that they know, and an exit code is not. Where to look: golden-file tests with an empty golden, approval suites (`sota-testing` rules/06 §6.4) whose approved set was never populated, gates seeded from a baseline run that itself failed, and any `set(old) - set(new)` regression check. ### 2.3 Cross-scale delta Run the same stage on a small and a large input. **Output that does not grow with input is suspect.** Findings, rows, log lines, bytes written, duration — pick a quantity the work should move and compare the two runs. This is the cheap version of `rules/13` §1: it catches a threshold-gated path without finding the threshold first. ### 2.4 Telemetry silence A stage that emits no log lines cannot be distinguished from a stage that did nothing. Silence is not evidence of health; it is absence of evidence. Any stage on a data path emits at least a start/finish pair carrying its denominator (§2.2). See rules/10 §3 for the degraded-control helper and the gauge that stays 1 while a control is degraded. **The inverse also holds, and it is the harder half: speech is not evidence of health either**, when the claim is sited *upstream* of the effect it describes. A line reading `1 adjudicated` proves a count was computed there, not that the count survived the rest of the function. Site the claim in the consumer, derived from the value received — `rules/14` §1. ### 2.5 Did the changed code execute? **After any fix, prove the new path ran.** A fix that is never reached is indistinguishable from a fix that works — and both look like a green suite. The cheap proof: make the new branch emit exactly once (a log line, a counter, a one-shot `print`), run the real workload, and show the emission. **Placement is a precondition of that proof**: an emission establishes that the line it sits on ran, not that its result survived the suffix of the function — the filter, early return or reassignment that comes after it. Put the emission where the value is *consumed*, or it answers a weaker question than the one you asked (`rules/14` §1). The same trap bites mutation testing: an editable install, a copied tree, a stale image, or cached bytecode means the code you edited may not be the code that ran (rules/12 §1). Assert the runtime effect before trusting any before/after result. ### 2.6 When you cannot state the right answer, state how it must change The reason a tool emitting nothing survives review is that nobody holds an oracle for its output. That difficulty has a name — the **test oracle problem** (Barr, Harman, McMinn, Shahbaz & Yoo, *IEEE TSE* 41(5):507–525, 2015, doi:10.1109/TSE.2014.2372785). You cannot write "the analyser should find 1,283 functions" for an arbitrary repository, so nothing in the pipeline contradicts "it found 0", and §1's silent zero ships. A **metamorphic relation** gets you an oracle anyway: you cannot state the output, but you can state how it must *change*. Commit a fixture with N known functions and assert the extracted count is N; add one and assert the count rises; remove half and assert it falls. `sota-testing` rules/06 §4 owns the technique for application code — the use here is different and cheaper. It is a **liveness oracle for a tool whose correct output you do not know**, and it is the one diagnostic in this file that catches an analyser emitting an empty-but-well-formed artifact while exiting 0. §2.3's cross-scale delta is the same idea without a fixture; the fixture buys you an absolute assertion instead of a relative one, and it belongs in CI rather than in an audit. ### 2.7 Provenance of the rows, not just the count §2.2 asks a **gate** how many items it examined. This asks an **analysis** where its rows came from — the same arithmetic one level up, and the worse failure, because the contaminated number looks like the better one. A sink that both the test suite and the real system write, with the same name in the same place, is one population to every tool that reads it and two populations in fact. Two instances, both 2026-09-02. Field-reported: of 226 log files in one directory, **215 were test fixtures**, identifiable only afterwards by a uniform verdict count and a `[test-provider]` string — every aggregate over it was ~90% test data. And in this library's own harness, where `evals/results/durations.tsv` received `--selftest` runs, usage errors and real measurements alike: **46 of 60 rows were sub-10s aborts (77%)**, and **3 of the 14 real runs had been compared against one**. The runner printed `previous 0.1s — 6340.0x slower <-- CHECK THIS` on its first genuine measurement, and the next real run would have read `previous 0.0s (no usable ratio)` against an abort logged 92 seconds after a good 3651.8s run — so §2.1, the diagnostic that ledger exists to implement, was inert for the most-run runner in the repo. The cause was one line of ordering: `note_work(len(cases))` ran *before* the `--selftest` branch, so the runner's own scorer test recorded the same denominator as a measurement. **The third field case is why this is a diagnostic and not a hygiene note.** Contamination is taught as a source of false positives. There it *destroyed a true finding* — a rate read 8.5% across the shared directory against ~100% on real runs — and it argued more persuasively in that direction, because the contaminated aggregate carried the larger n. "5,000 samples" outranks "149" in review, and the 149 were the real ones. A false positive dies in review; a finding withdrawn on contaminated evidence leaves nothing behind at all — no retraction to grep for, no red build, no second look. Rule for BUILD: the writer stamps, not the reader — `sota-observability` rules/05 §8a. Rule for AUDIT: every number reported from a shared sink states its **exclusion filter and the count on both sides of it** (`149 of 5,000 after excluding test-provider runs`); a bare n from a shared sink is not a measurement. And treat an **unexplained** jump in n when you widen the source as contamination, not power — widening one shard to all shards legitimately multiplies n, so the test is whether you can name where the new rows came from. If you cannot, you did not widen the sample; you merged two populations. §2.3 reads the same arithmetic from the other end. ## 3. Five classes rules/10 does not cover Moved to [`rules/13`](13-context-dependent-silence.md) — scale-dependent silence, stale-artifact no-ops, format assumptions generalised from one sample, contract drift at an undeclared seam, and location-dependent silence. §2's diagnostics are how you notice one; `rules/13` is what you are looking at once you do. ## 4. An assert is not a control in production An assertion is a developer-facing invariant check. In three major runtimes it is **removed or disabled** in the configuration production most often runs, so a control implemented as an assert is a guaranteed no-op there. Verified 2026-07-30 by running each case: | Runtime | Command | Result | |---|---|---| | Python | `python3 -O prog.py` / `PYTHONOPTIMIZE=1` | failing `assert` vanished; program printed `passed` | | C/C++ | `cc -DNDEBUG` | `assert(x>0)` compiled out; program printed `passed` | | Java | default `java` (no `-ea`) | assertions are **disabled by default** at runtime | Java's own documentation states it plainly: *"By default, assertions are disabled at runtime"*, and once disabled they are *"essentially equivalent to empty statements in semantics and performance"* ([Oracle, Programming with Assertions](https://docs.oracle.com/javase/8/docs/technotes/guides/language/assert.html)). Rules: - **Never** implement validation, authorization, bounds, or any data-integrity check as an assert. Use an explicit conditional that raises, returns a denial, or exits — code that survives optimisation flags. - Asserts are fine for *impossible* internal states you want loud in development. - AUDIT: grep for assertions on validation, authz, size, and parsing paths, then check what flags the **deployment** actually uses (`-O`, `NDEBUG`, `PYTHONOPTIMIZE`, the absence of `-ea`). A control removed by a build flag is the purest form of this family: source code that reads correct and does not exist at runtime. Language specifics: `sota-python` rules/05, `sota-c-cpp` rules/04, `sota-jvm` rules/04. ## 5. Evidence — one discriminating proof per finding Evidence must **distinguish broken from fine**. Reasoning is not evidence, and a mechanism you did not trigger is not a confirmed finding. | Class | The proof that discriminates | |---|---| | Vacuous control | **Mutation test.** Inject the exact failure the control claims to catch, run it, show it still reports green (rules/12 §1) | | Silent zero | Show the failure return is **identical** to the success-but-empty return, and name **one consumer** that cannot distinguish them | | Scale-dependent | State the trigger numerically; show fixtures never cross it; measure both scales where cheap | | Stale artifact | Change the omitted input; show the key unchanged and the stale artifact reused | | Format assumption | A **real** sample from the installed version that violates the assumption | Label every finding exactly one of: - **ACTIVE** — proven to have fired. Cite the log line, output, or measurement. - **LATENT** — mechanism verified in the code; verified **not** to have fired, and you say how you checked. Report it; do not inflate it to ACTIVE. - **REFUTED** — you suspected it and the evidence says no. **Report these too**: a refuted suspicion stops the next auditor re-raising it (`sota/rules/03` §4). **Not findings** (disqualifiers): - "This could fail if…" with no proof that it does, or that no guard exists. - A control you called vacuous but never made fail. - Any conclusion drawn from a name, comment, or docstring rather than the code. Comments are a **hypothesis** about behaviour and are themselves prime hunting ground — a comment describing a check the code does not implement is the canonical vacuous control. - Anything you cannot tie to a `file:line`, a command's output, or a log line. **Rank by blast radius × silence.** A loud partial failure outranks nothing; a silent total failure outranks everything. **Fix + risk — state whether the fix moves a decision boundary.** Making an inert detector work changes what the system reports. If the fix alters a decision boundary (a scanner that now fires, a validator that now rejects), it needs validation against a **labelled corpus — known-bad and known-good — before shipping**, or you trade a silent miss for a silent flood, and the flood gets the control switched off. Say so in the finding rather than shipping the fix blind. ## 6. Where to hunt, in order 1. **Every gate**: CI jobs, pre-commit hooks, health and readiness checks, admission/validation webhooks, authz checks, quality gates. Mutation-test each one — a gate you have never seen reject anything is unverified (rules/14 §4). 2. **The tests *of* those gates**: does any test assert the gate **fails** on bad input? Happy-path-only tests are how vacuous controls survive review (`sota-testing` rules/09). 3. **Error handling on the main data path** — every catch/except between input and output (rules/16 §2.4). 4. **Fallback, retry, degrade, and "continue anyway" branches** — verify the fallback *actually engages*. A log line saying it will is not proof that it does. 5. **Shell and CI glue**: pipelines without `pipefail` mask a non-final failure; globs that match nothing; `find -exec` over an empty set; `|| true`; any command whose exit code is discarded (`sota-shell-scripting` rules/01). 6. **Caches, tags, fingerprints** (`rules/13` §2). 7. **Feature flags and config**: is the value **read** *and also* **applied**? A config field that parses, validates, and is never plumbed to the code path it names is a silent no-op — distinct from rules/16 §2.5, where the flag *is* applied, just more broadly than its name claims. Trace one flag end-to-end from file to the branch it is supposed to control. 8. **In-band sentinels on a compared value** — a number whose domain includes an "absent/unknown/error" marker (`-1`, `0`, `""`, `9999-12-31`). It defeats a presence check (`-1` is truthy), and because it has an **ordering** it loses every `<` and wins every `>`, so a guard silently skips one way and fires spuriously the other. Grep the *producer* — one function returning the same constant from a not-found branch and an error branch — then look for the **asymmetric guard**: a comparison with one operand filtered against the sentinel and the other not. That asymmetry, not the constant, is the finding (`sota-architecture` rules/02 §8a). Start by enumerating every gate, guard, and audit in the codebase. For each: read the comment, read the code, then **make it fail on purpose**. The controls you cannot make fail are the finding. Do one thing before any of that reading: **run every script CI, a hook, or a runbook references, and record which produce output and which do not.** A measurement tool nobody has executed this quarter is presumed dead until it prints something — the ones needing credentials, a daemon, a rules directory, a model file, or a network fail in precisely the way a clean result looks, and one environment change (auth switched on) kills them all at once. ## 7. Then turn the lens around Every diagnostic above is run *by* something — a script, a gate, a scorer, a grep. Each of those is a control by the definition in §1, and the sweep is not finished until they have been held to the same standard: **rules/12** carries the mutation probe; **rules/15** carries the bar an instrument must clear before its number is quoted, and the guard that is an instance of what it guards. A finding produced by an unvalidated instrument is not yet a finding. --- ## Audit checklist - [ ] **Duration recorded per stage** and compared against the work claimed — any stage returning "nothing found" far faster than its claimed work allows flagged, with the measured seconds and input size (§2.1)? - [ ] **Every gate reports its denominator** (`ok (N items)`), and an unexpected **zero scope fails closed** — no `0 checked, 0 failed, exit 0` anywhere (§2.2)? - [ ] Every **differential, regression or equivalence check asserts its reference set is non-empty and loaded** before its result reads as a pass (§2.2a) — an empty baseline makes `baseline - current` empty for every input, forever; a legitimate empty reference carries a greppable opt-out flag, never a silent exit code? - [ ] Every **generated** result checked against the cap that bounded it before parsing (`output_tokens == max_tokens`, rows == `LIMIT`), and parse-error offsets logged beside the document size (§2.2)? - [ ] Any **rate or average used in an argument** re-read at a resolution finer than the event it is about, and its per-event cost inspected — a burst and a trickle share a mean, and 12 µs vs 12 s are different mechanisms with identical counts (§2.1)? - [ ] Cross-scale delta run on at least the stages that gate on size: output that does not grow with input investigated (§2.3)? - [ ] No stage on a data path is **silent** — start/finish with counts (§2.4)? - [ ] Any tool whose correct output cannot be stated carries a **metamorphic liveness check** in CI — a fixture with a known count, and an assertion that the count moves when the input does (§2.6)? - [ ] After each fix, the **new path proven to have executed** (emission, counter, or asserted runtime effect), not just "tests pass" (§2.5)? - [ ] Unbounded traversals/recursion/variable-length queries bounded, and every **size-gated path** exercised by a fixture that crosses the threshold (`rules/13` §1)? - [ ] Truncating budgets degrade **loudly and in the returned value** (`coverage: partial`), never only in a log line (`rules/13` §1)? - [ ] Every cache/tag/fingerprint key audited with "what input can change while the key stays constant?" — tool/ruleset **version** included (`rules/13` §2)? - [ ] External-interface parsers validated against the **declared schema** and a real sample from the installed version, not one observed response; numeric parsing rejects trailing garbage (`rules/13` §3)? - [ ] Every aggregate drawn from a sink that **anything but production writes to** states its exclusion filter and the count on both sides of it, and no finding was withdrawn on a number whose n grew unexplained (§2.7)? - [ ] **No security, authz, bounds, or data-integrity check implemented as an `assert`**, and the deployment's flags (`-O`, `NDEBUG`, `PYTHONOPTIMIZE`, missing `-ea`) checked against any assert on a control path (§4)? - [ ] Every finding carries a **discriminating proof** for its class, and is labelled **ACTIVE / LATENT / REFUTED** — refuted ones reported too (§5)? - [ ] Findings ranked by **blast radius × silence**, and any fix that moves a decision boundary flagged as needing labelled known-bad/known-good validation before shipping (§5)? - [ ] Config and feature flags traced **end-to-end**: read, validated, *and* applied to the branch they name (§6.7)? - [ ] Every artifact handed **between stages** has its layout, writer and reader named, and any producer change validated by **running the consumer on real output** — not by each side's own tests (`rules/13` §4)? - [ ] Every script CI, a hook or a runbook references **actually executed this pass**, the silent ones recorded as dead until proven otherwise (§6)? - [ ] **Environment-dependent predicates**: any filter tested against an absolute path, hostname, username, env var or locale — `grep -rn "\.parts\|os.environ\| gethostname" ` near a comprehension. Run the suite from a `mktemp -d` clone, not the working tree; on macOS that path resolves under `/private`, which is exactly the component such filters tend to exclude. - [ ] **Every collection a suite iterates has a non-empty assertion** — without one an empty parameter set reports SKIPPED and the suite passes vacuously. - [ ] The tools that produced these findings held to the same standard — mutation probe (**rules/12**), instrument bar and guard recursion (**rules/15**) (§7)? -
12-verifying-the-verifier.md 25 KB
# Verifying the Verifier — the mutation probe rules/10 catalogs controls that look enabled and do nothing. rules/11 is the sweep that finds them at codebase scale. This file is the third move, and the one most often skipped: **proving that a specific control works** — by making it fail on purpose, and by putting that known-bad somewhere it will survive. Turning the same suspicion on **everything that did the proving** — the scorers, gates, benchmarks, watchers and guards, and the instrument you are auditing with right now — is `rules/15`, split out of this file on 2026-09-06 at the seam its own subtitle used to name. Section numbers did not change in the move: `rules/15` §2 and `rules/15` §3 mean there exactly what they meant here. The reason it earns its own file is an asymmetry. A broken feature produces a complaint. **A broken verifier produces a green tick or a number**, and both are believed, quoted, and put in a README. Nothing downstream distinguishes "we checked and it was fine" from "the check could not have failed". Use it in BUILD to decide whether a control you just wrote is actually held in place by anything, and in AUDIT as the pass that runs *after* rules/10 and rules/11 have produced findings — because a finding produced by an unvalidated instrument is not yet a finding (`rules/15` §2, §3). **Outside software this is settled practice, under four different names.** Every discipline that has to trust a detector tests it with something it *must* catch: a **proof test**, which exists because a safety function's dangerous failures stay hidden until the moment of demand (IEC 61508's framing); a **positive control** in an assay, where a run whose known-positive comes back negative is void rather than clean; **built-in test** on aircraft systems; and adversary emulation in detection engineering, where `sota-detection-engineering` rules/06 already requires proving a detection fires against the real technique. Software CI tests the code with the tests and almost never tests the tests, gates and scanners with a known-bad. Closing that asymmetry is what this file is for. The design-level generalisation is **poka-yoke**: prefer making the inert state impossible or self-announcing over making it detectable. Related: the instruments and guards that do the checking → `rules/15`; the inert-control catalog → rules/10; the codebase-scale sweep → rules/11; vacuous tests in general, mutation testing, and watching a security test fail → `sota-testing` rules/02, rules/06 and rules/09; the audit-level evidence and refutation standard → `sota/rules/03` §2 and §4. --- ## 1. The mutation probe — make the control fail on purpose A test that passes against broken code is worse than no test: it manufactures false safety. `sota-testing` rules/02 (assertion-free, tautological), rules/06 (mutation testing), and rules/09 (security regression tests must be watched to fail) own the general doctrine. What this file adds is the targeted procedure for a **security control**: 1. Replace the control's body with the permissive no-op — `return []`, `return True`, `pass`. 2. Run the suite. 3. **Nothing fails ⇒ that control is untested**, regardless of how many tests name it. Report it as a finding, not as a coverage note. Two traps that make step 3 lie: - **Masked by a missing dependency.** The assertion passes because the feature was disabled for an *unrelated* reason (rules/16 §2.2) — the real path never ran. Force the dependency present (monkeypatch the availability check) so the control is actually exercised. - **The mutation did not take.** Commonest cause first, because it is not an environment fault at all: **the substitution matched nothing.** A regex that does not match, a `sed` delimiter colliding with a character in the pattern (`s|…|…|` against a pattern containing `|`), an edit tool that no-ops. Then the environmental ones — editable installs, copied/rsync'd trees, stale bytecode, cached images — and a **formatter reflow**, where a multi-line revert silently matches nothing because `ruff format`/`black`/`prettier` folded the target onto one line. Two fixes, and the cheaper one is stronger. **Assert the pattern is present before you write**, which fails at *mutation* time: ```python assert old in text, f"mutation {n} did not match -- harness bug, not a result" ``` and **assert the mutation's runtime effect** — make the no-op print or raise once — which fails at *interpretation* time. Field-measured: a three-mutation harness where one `sed` died loudly on a delimiter collision and another matched nothing in silence; the silent one made the self-test **pass**, and was read for half a minute as a real gap in the control being built. Only the loud sibling made the harness suspect at all (`rules/15` §2.1, sixth bullet — the harness is the newer artifact). A **third probe** costs one edit: leave code and fixture alone and point the assertion at a plausible **wrong expected value** — still passing means it is keyed to something true that is not evidence (`sota-testing` rules/06 §6.3). Then build the **structural** test that catches the class: assert the loaded rule count is non-zero, assert every reference-config key resolves, assert the documented default equals the parsed default, assert the control's telemetry is emitted. Instance tests catch today's bug; structural tests catch the next one. ## 1a. The other direction — the control that blocks everything §1's probe is **directional**. It installs the *permissive* no-op and asks what fails, which finds the control that does nothing. Nothing in it can find the opposite defect: an **enforcement** control — a cap, quota, limit, filter, allowlist, sandbox policy — set so tight that it refuses the legitimate case too. Both defects pass the same test, because a security suite asserts *refusal* (`sota-testing` rules/09 §1) and refusal is exactly what an over-tight control produces. The asymmetry is why only one of the two ever gets found. An inert control fails toward the attacker and nothing observable changes. An over-tight one fails toward the user and is loud — *in production*, weeks later, on the input nobody tested. **Every enforcement control needs two arms, and the deny arm is the one everybody writes:** 1. **Deny arm** — the abusive case is refused. (The one you already have.) 2. **Allow arm** — a *representative legitimate* case completes unchanged under the same policy. Not a reduced case, not a synthetic one: the real workload's ordinary input. **A negative control on the environment is not a negative control on the control.** Proving the machine can allocate a gigabyte says nothing about whether *your cap* permits legitimate work — an arm like that exercises the environment and passes whether or not the control exists at all. The allow arm has to run **through** the control. Worked instance, both arms measured (Go 1.26, linux/amd64, container, 2026-08-18): | memory cap | deny arm — over-budget allocation refused | allow arm — 200 MiB legitimate run completes | |---|---|---| | `ulimit -v` (`RLIMIT_AS`) | yes — **vacuously**: the process never starts | **no** — `fatal error: failed to reserve page summary memory` at `-v 512M` | | `ulimit -d` (`RLIMIT_DATA`) | yes — 400 MiB refused at `-d 128M` | yes — completes at `-d 512M` | Deny-only, the two configurations are indistinguishable and both read as "the cap works" — and the `RLIMIT_AS` row passes its deny arm **vacuously** (`rules/15` §3), for the same reason it fails the allow arm: nothing ever runs (`sota-sandboxing` rules/02 R7.2a). The allow arm is the only thing that separates a working budget from a control that refuses everything — and it is precisely the arm the deny-only habit drops. The same gap applies to a WAF ruleset, an egress allowlist, an input validator, an admission policy, and a rate limiter keyed too narrowly. ### 1a.1 A failed reproduction is an absence claim, and needs the same two arms §1a asks a *control* for an allow arm as well as a deny arm. The same requirement applies to an **experiment**, and nothing points it there — which is why it is skipped. *"It did not reproduce"* is a negative claim. It therefore already falls under the heavier burden `sota/SKILL.md` principle 3 puts on any absence — but it **does not feel like a search**, so the rule never fires. A refutation is reported as a result, not as a not-found, and passes unchallenged in a way "no instances of X" would not. Field-reported, and the reporter published the failure: a mechanism was reported to a reviewer as **REFUTED, twice, confidently**, from two harnesses that could not have produced any other outcome — the client short-circuited before sending the command under test, and the transport had no retry layer to exercise. The effect was not absent; **the instrument reached nothing**. What separated the third attempt from the first two was not insight, it was a control arm: identical code with the doubling switched off, proving the harness could produce *an* outcome at all. With it, the mechanism reproduced on the first run. - **An experiment that returns a null needs an arm that returns a non-null**, through the same code path, before the null is reportable. Without it, *"the effect is absent"* and *"my harness reaches nothing"* are the same output. - **State the falsifier before running** — then a null is a result rather than a mood. - **The cheap tell: a null that arrives instantly and identically on both runs.** A real refutation usually costs something — a different error, a partial result, a changed timing. Two byte-identical clean exits are more often a harness that never engaged. - **Say which arm you ran when you report a refutation.** A refutation with no control arm should be labelled *"did not reproduce here"*, never *"refuted"* — the first is about your instrument, the second about the world. ## 1b. Where the probe lives decides whether it survives §1 and §1a describe probes as things you *run*. In any suite that keeps them they are also things somebody *maintains*, and the two usual homes both leak: - **Beside the checks** — a separate harness or CI job that injects a known-bad per check. It proves today's checks can fail. It says nothing about the check added next week, because joining the harness is a **convention**, enforced by a sentence in a contributing guide and by whoever happens to review the PR. - **In a reviewer's memory** — "we watched it fail once". Unrecorded, and gone with the person. Prefer a third home: **a mode of the tool itself** — `--self-test`, a `doctor` subcommand — that walks the same registry of checks the ordinary run walks, injects each check's declared known-bad, and asserts that *that check, by name* is the one that reports. "Every check can go red" then stops being a property of who last edited the suite and becomes a property of the suite: - a check with **no declared known-bad fails the self-test** instead of being silently exempt, so the probe cannot be forgotten at the moment a check is added — which is the only moment it is ever forgotten; - the probe **ships with the tool**, so it runs against the operator's own installation — exactly where §1's stale-install and missing-dependency traps bite (rules/11 §2.5), and where a harness that only ever runs in your CI cannot look; - the known-bad sits **next to the check's definition**, where the reviewer of a new check is already reading. **The self-test is itself an instrument** (`rules/15` §2) and inherits every rule there. Three that decide whether its output means anything: - **Attribute the catch.** Requiring "the run failed" accepts a non-zero exit for an unrelated reason as proof — a **false pass**, not a catch. Assert the intended check is the one that complains. - **Assert the mutation took** (§1). A probe whose hardcoded known-bad has drifted out of sync with the check reports `NOT CAUGHT` and accuses a healthy check. - **Report the denominator**: checks probed over checks registered. The gap is the interesting number, and it is invisible in a pass/fail line. - **Assert the mutation crossed the threshold**, not merely that the tree changed — the bullet above answers only the second question, and a probe can keep applying cleanly while it stops biting (§1d). Two things a self-test does **not** establish, and both belong in its output rather than in a reader's assumption: checks whose known-bad needs state it cannot fabricate (a tag, a merge base, an mtime, a live upstream) are **skipped**, and a skip must print its reason instead of folding into the pass count; and a check that can fail may still have stopped covering the code that matters — scope drift is a separate failure with no diff to the check (`sota-devsecops` rules/09 §2). ### 1b.1 A planned change is a legitimate source of a gate Gates are usually said to come from incidents — something failed, so now it is checked. That under-counts one case badly. **Before a rename, a move, a renumber or a split, ask what class of reference or assumption it invalidates and whether anything would report it.** Where the answer is *nothing would*, build that check first: the refactor then becomes its own negative control, because you can watch the check go red on damage you caused deliberately. The usual "has this already failed?" filter reads **no** at proposal time here, and that reading is unreliable — a class nothing reports has no incident history *by construction*. Running the check is what answers it. Worked case: a documentation tree carried ~1,300 prose section references (`rules/15 §2.4`, `rules/13 §5`) that no link checker could see, because a link checker resolves `[text](file.md)` and a `§` reference is prose. The check was written to protect a planned file split; on its **first run against the unmodified tree** it found six live breakages, then caught 27 more the split itself caused. Both sets were invisible the day before, and the second would have shipped. Two cautions from the same case, because a check like this is an instrument (`rules/15` §2). **Read the findings before believing the count** — successive drafts reported 46, 29, 17, 13 and finally 6, and the shrinkage was not tuning but discovering two authoring conventions the first draft had not modelled, one of which made it flag *correct* references (`rules/15` §2.1's "generalised from one sample", committed by the instrument itself). And **fail open on ambiguity**: where a reference could plausibly resolve more than one way, accept it. A gate that flags correct work gets switched off, which leaves you worse off than no gate. ## 1c. The control that was correct, and then edited §1 probes a control that is **wrong**, §1a one that blocks **everything**, `rules/14` a control **not in force**. All three ask a correctness question about a control somebody else wrote. None of them asks the integrity question: *this control is correct today — who can change it?* **Detection is bounded by the integrity of the detector.** A control that a rewrite can delete provides exactly as much protection as the write permissions on its own source file, whatever its logic says. Field-reported. A policy gate protected two artifacts it had enumerated — its decision engine and its rule pack — staging both read-only outside the writable tree and recording both in an integrity manifest. It was correct about both, and short by one: **the gate's own module was an editable install**, so the import resolved *inside* the directory the gate permitted writes to. Replacing that module with a no-op allowed every subsequent call. The write was permitted by every rule pack tested, **including the fail-closed one** — because that pack constrained shell commands, and the write arrived through an editor tool the pack did not model. Three things that generalises to: - **Derive the protected set from what the decision depends on, not from what was obvious when the manifest was written.** The artifact most often missing from a control's own integrity list is the code doing the protecting. Enumerating inputs and policy files while omitting the evaluator is the normal shape of this, not an unusual one. - **Manifesting the verifier is worth doing and is not sufficient.** Whoever rewrites the entry module deletes the verification call *in the same edit*, so a manifest checked by the thing being manifested closes tampering with every module except the one that matters most. The fix has to be **location**: execute the control from a copy the constrained principal cannot write to, with the manifest and detection as the layer that catches the rest. - **Enumerate the writers by capability, not by the mechanism you modelled.** A policy that constrains shell commands does not constrain a file-writing tool, an editor integration, a language server, or a package manager doing an editable reinstall. Ask which *principals* can write the path, then which tools each of them has — `sota-skill-security` rules/02 §2 makes the same argument for instruction files, and this is the executable case of it. **Not the same as `rules/14` §8.** There, a *benign* neighbouring process overwrites what a control produced, and the fix is a predicate the innocent state fails. Here the actor is the principal the control constrains, the target is the control's own code, and no predicate on the output helps — the output is whatever the replacement chooses to say. ### 1c.1 Some residuals are protocol-level, and the obvious fix costs more than it buys Where **"allow" is expressed as silence**, a control replaced by a no-op is byte-identical to a permitted call. Nothing downstream can tell them apart, and a `|| deny` wrapper does not fire — that wrapper exists for a *crash*, and this is not a crash. The tempting fix is to make silence anomalous by emitting an explicit allow. Price it first: an explicit allow can **override a separate permission layer** that was relying on the same silence, trading a real defence for a tamper signal. That is a worse trade than the residual. So: **price a fix against the layer it disables.** Writing the residual down — as a named test that documents what would not be detected, and an entry in whatever ledger records accepted risk — and leaving it open can be the correct call. What is not correct is leaving it undescribed, because the next reader cannot distinguish an accepted residual from an oversight (`rules/15` §2). ## 1d. The probe that still applies, and no longer bites §1b's *assert the mutation took* answers one question: **did the tree change?** It is a tree-dirty test and nothing more. So it catches a known-bad whose **literal has drifted** — the mutation stops matching, nothing changes, the guard fires. It is blind to the sibling case: **the mutation applies cleanly, the tree really does change, and the result still does not cross the threshold**, because the subject improved underneath it. Measured 2026-09-13 in this library's own harness. A probe for a **200-line cap** on an always-loaded file appended exactly **one line** — effective when written, because the file sat at 199, and fitted to that momentary state with a margin of one. Refactoring the file to 169 lines, a deliberate improvement that removed the reason the cap kept being breached, left the same mutation applying perfectly and breaching nothing. The gate correctly passed; the harness reported `NOT CAUGHT`. Two things make it worth naming. **The improvement and the disarming were the same edit** — no review step separates them, because the diff improves a file and touches no probe. And **it is a decay, not a break**: the probe gets weaker as the subject gets healthier, in the one direction nobody is watching. **Which probes have this shape, precisely.** Not "anything with a numeric threshold" — the deciding property is a **fixed delta smaller than the threshold it must cross**, so that the probe's strength *is* the subject's current slack. Three constructions in the same harness are structurally immune, and two of them are the fix: - **Overshoot the threshold outright.** A cap probe that appends 600 lines against a 500-line cap breaches from any starting size. - **Write an absolute value**, not a delta — set the count to 999, the date to 2099. - **Pin the threshold to the subject** so slack is zero by construction. A ratchet that fails when the measured value falls *below* its pin ("a slack ratchet is not a ratchet") can never accumulate the slack this failure needs. Otherwise, **pin the mutation to the threshold**: measure the subject at probe time, compute what it takes to cross from wherever it is, clamp, and print both numbers so a later reader sees what the probe did. ```sh n=$(awk 'END{print NR}' "$f"); need=$(( LIMIT - n + 1 )) [ "$need" -lt 1 ] && need=1 # subject already in breach: still mutate, or the # probe reports PROBE BROKEN on a genuine violation ``` - **Re-run the known-bads after a refactor**, not only after a change to a check. A refactor can disarm a probe as easily as it can break a gate, and only one of those is loud. - **`NOT CAUGHT` is ambiguous, not an accusation in either direction.** It means *the gate passed on a mutated tree*, which is equally consistent with an inert gate — the thing this harness exists to find — and a decayed probe. Separate them by asking whether the mutation **crossed the threshold**, not merely whether the tree changed; the standard guard answers only the second question. `rules/15` §2.1 puts the prior on the newer artifact; this is the case where the *older* artifact's **assumptions** are what just changed, so the same suspicion applies for a different reason. - **This is the mirror of `sota-devsecops` rules/09 §2.** There a refactor moves code out from under a **gate** while the known-bad stays valid; here a refactor disarms the **probe** while the gate stays valid. Both are invisible in the diff, and both take the same remedy: have the check print the number it enumerated, and fail when that number moves. ## Audit checklist - [ ] **Does any probe mutate by a fixed delta *smaller than the cap it must cross*?** (§1d) That, not "a numeric threshold", is the shape that decays: the probe's strength is the subject's current slack, so improving the subject disarms it while the mutation still applies, the tree still changes, and *assert the mutation took* passes. Equality and ratchet comparisons are immune (no slack exists); caps and budgets are not. Fix by overshooting the cap, writing an absolute value, pinning the threshold to the subject, or computing the crossing at probe time — and re-run the known-bads after a **refactor**, not only after a check changes. - [ ] **Mutation probe run on security-critical paths** — control body replaced with the permissive no-op, with the dependency forced present and the mutation's **runtime effect asserted** before trusting a green run (§1)? - [ ] Negative controls run as a **mode of the tool** (`--self-test`, `doctor`), not only as a harness beside it — a check with no declared known-bad fails the self-test, each probe asserts the **named** check caught it rather than accepting any non-zero exit, skips print their reason, and the run reports checks-probed over checks-registered (§1b)? - [ ] **Any reported refutation — does the experiment have a control arm?** (§1a.1) *"It did not reproduce"* is an absence claim under principle 3's heavier burden, but it does not feel like a search so the rule never fires. Without an arm that returns a **non-null** through the same path, "the effect is absent" and "my harness reaches nothing" are the same output. Tell: a null arriving **instantly and identically on both runs**. Label it *"did not reproduce here"*, never *"refuted"*. - [ ] Every **enforcement** control (cap, quota, filter, allowlist, sandbox policy) carries an **allow arm** as well as a deny arm — a representative legitimate case completing *through* the control, not against the bare environment — so "blocks everything" cannot read as "works" (§1a)? - [ ] Structural tests added alongside the instance test — non-zero loaded rule count, every reference-config key resolving, documented default equal to parsed default, control telemetry actually emitted (§1)? - [ ] Is the control's **own executable or source** on the list of things something protects, and is that list derived from what the decision **depends on** rather than from what was obvious when it was written (§1c)? - [ ] Can the principal the control constrains **write to the control's own code**, by any tool — including tools the control does not model (an editor integration, a package manager, an editable reinstall), not just the mechanism its policy describes (§1c)? - [ ] If the control were replaced by a **no-op**, could anything downstream tell? Where "allow" is silence the answer is **no** — say so explicitly, and record the residual rather than reaching for an explicit-allow signal that may override a separate permission layer (§1c.1). -
13-context-dependent-silence.md 16 KB
# 13 — Context-Dependent Silence: correct here, broken there Five defect classes that `rules/10` does not cover and `rules/11`'s diagnostics point at but do not describe. What unites them is **conditionality**: each one is genuinely correct under the condition you tested and silently wrong under the one you shipped into — small input vs large, fresh artifact vs stale, the sample format vs the next one, one component vs the seam between two, this environment vs that one. That is why they survive review. There is no wrong line to find: the code is right, and the *condition* is what changed. A test written against the passing condition passes forever, which makes every one of these invisible to the same-context checks that would catch an ordinary bug. **Read this after `rules/11` §2** — its diagnostics (duration, denominator, cross-scale delta, telemetry silence, execution proof) are how you *notice* one of these; this file is what you are looking at once you do. The inert-control catalog is `rules/10`; proving a control works is `rules/12`. ## 1. Scale-dependent silence — correct small, broken large Code that is right on fixtures and pathological or wrong in production, where the difference is a number nobody crossed in a test. Shapes to look for: - **Unbounded traversal**: recursion without a depth cap; variable-length graph queries with no bound (`[:AST*]` rather than `[:AST*1..12]`, Cypher variable-length patterns, SQL recursive CTEs without a depth column). Unbounded is not "thorough" — it is a query that times out and returns nothing on the inputs that matter most. - **Whole-input reads**: loading a file, table, or response fully into memory. - **Per-item queries inside a loop** over an unbounded set (N+1). - **Budgets that truncate rather than fail** (time, size, row, token caps). - **Paths gated behind a size threshold that fixtures never cross** — chunking, sharding, pagination, streaming, multi-part upload. The gated branch is effectively untested code that only ever runs in production. The tell: **the threshold is a literal in the code and no fixture crosses it.** **Budget exhaustion is the silent sub-case worth its own rule.** A stage that logs `skipped 12 rules (budget exhausted)` at INFO and returns a normal-looking result has reported *partial* coverage as *complete*. The consumer cannot tell "clean" from "clean as far as we got". Rule: a truncating budget degrades loudly (rules/10 §3) **and** the result carries the partiality in its own value — `coverage: partial`, `skipped: 12`, a distinct status — never only in a log line. An audit finding here is the missing field, not the budget. Proof required: state the trigger **numerically** (threshold, node count, row count) and show the fixtures never reach it. Measure at both scales where cheap. Performance framing of the same code → `sota-performance` rules/01; the fixture side → `sota-testing` rules/03. ## 2. Stale-artifact no-op — a key narrower than the behaviour A cache, tag, fingerprint, or memo keyed on **fewer inputs than actually determine the output**, so a real change is silently ignored and a stale artifact is reused. Nothing errors; the pipeline is simply operating on last week's answer. Ask of every key: **what input can change while the key stays constant?** Where they hide: cache keys, memoization decorators, content hashes, image and artifact tags, `if exists: skip`, lockfiles, generated code checked into the repo, incremental-build stamps. The usual omissions are not the source file — they are everything *around* it: the tool or ruleset **version**, compiler/interpreter flags, the config that selects behaviour, the environment or platform, and the schema the output is shaped by. A scanner cache keyed on the target's hash but not on the ruleset version keeps serving pre-rule-update results. Proof required: change the omitted input, show the key is unchanged, and show the stale artifact being reused (a cache-hit log, an unchanged output hash, a build that skipped the step). Rule for BUILD: the key covers every input that changes the output, tool versions included; when unsure, add a version salt and take the cheap re-computation. See `sota-devsecops` rules/04 for the security-relevant case (a cache key must also carry the **trust context**, or a lower-trust build can poison a release). ## 3. Format assumption generalised from one sample The same error made about a **sentence** rather than a parser — a mechanism asserted from one artifact — is `rules/15` §2.1's third bullet. A parser built against one observed sample of an external interface, where a sibling field, a newer version, or an edge case has another shape. Indexing into external JSON/CSV (`x[0]`, chained `.get().get()`), assumed column counts, a tagged union read as a flat record, an optional field treated as required. **The silent sub-case: lenient parsers that accept malformed input and return a plausible-but-wrong value** rather than raising. Verified 2026-07-30 on the installed runtimes: ```js parseInt("12abc") // 12 — trailing garbage ignored parseInt("") // NaN — which then propagates as a number Number(" 12 ") // 12 — surrounding whitespace accepted ``` ```python int(" 12 \n") # 12 — whitespace accepted float("1_0") # 10.0 — underscore separators accepted: "1_0" is not 1.0 ``` A corrupted or unexpected field therefore yields *a number*, not an error, and every downstream stage treats it as data. This is a silent zero with a plausible disguise. Proof required: produce a **real sample from the installed version** that violates the assumption — captured output, a recorded response, a fixture pulled from the actual tool. Not a hypothetical. Rule for BUILD: parse strictly at the boundary — reject trailing garbage, require the declared type, validate against the interface's schema rather than against the one response you saw — and record which interface **version** you validated against, because that is the input §2 says your cache key is probably missing. ## 4. Contract drift by interaction — the seam nobody declared Neither component is wrong. A change to a **producer** silently alters a layout its **consumer** depends on: file name, directory shape, column order, separator, units, encoding, per-label files where there was one combined file. Both sides pass their own tests — each knows only its own side of the seam. What separates this from §3 is *where the assumption lives*: §3 is a consumer generalising from one sample of an external interface; this is an internal seam **no schema describes**, so nothing exists for a registry or a compat check to compare. Declared contracts are `sota-data-engineering` rules/04 and `sota-testing` rules/04 — this class is what is left when none exists. The high-yield trigger is a change that is not a code change: **selecting a different backend, engine, driver, or frontend** for one class of input, where the new one writes a different layout as a side effect. One config line moves, the format change is undocumented, and the stage downstream reads zero rows and reports "produced no output" — a silent zero (§1) with an innocent-looking cause. Rule for BUILD: when you change a producer, **run the consumer on that producer's real output** before merging; isolation tests pass on both sides while the seam is broken. Rule for AUDIT: for every artifact handed between stages, name its layout, its writer and its reader — where no schema pins them, the pair is a finding awaiting its first change. ## 4a. The defaulted read — and the seam whose producer is a model §4's seam has a fixed producer: change it, run the consumer on its real output, done. That build rule dissolves when the producer is a **model**. There is no change event to run the consumer after — the field name is sampled from a distribution on every call, so the same code is right on one response and silent on the next, and neither side has a version to pin. What makes it silent either way is the **defaulted read**. `d.get(k, default)` against a dict you did not construct converts a detectable disagreement into a plausible constant: the consumer names one key, the producer emits another, nothing raises. A `KeyError` would have surfaced it in minutes; the default runs for weeks. Field-reported 2026-09-02, four instances in one service — a recorder reading `confidence` where the producer wrote `final_confidence` (168 rows stored `0.0`), and a store reading `verdict.get("reasoning", "")` where the model answered `reason` (221 of 232 rows recorded an empty explanation, in the phase that most needed one). ```python # BAD — an absent key and a legitimate zero are the same value row.confidence = result.get("confidence", 0.0) # GOOD — the absent case is distinguishable from the default raw = result.get("confidence", _MISSING) if raw is _MISSING: log.warning("verdict_key_absent", expected="confidence", got=sorted(result)) raw = 0.0 ``` - **Reading a key you did not write, with a default, is a silent-failure site.** Prefer `d[k]` and let it raise. Where a default is genuinely right, absence must be **distinguishable from the default value** — a sentinel, a counter, a log line. The write-side twin — a literal `[]` or `""` emitted because the upstream dict never carried the value — is `rules/16` §2.3, the same finding one function earlier. - **Where the producer is a model, no static check closes the seam.** The prompt naming a field and the code reading that field can both be correct while the model answers with a synonym: no diff, no version bump, no producer test that fails. The sound detector is **runtime** — diff the keys the response carried against the keys anything consumed, and log every returned key nothing read. **An ignored key is the signal**; it is the only artifact of the disagreement that exists on both sides. - This is the mirror of the tolerant-reader rule (`sota-api-design` rules/02 §3): ignoring unknown fields stays correct for evolvability, and what is added here is that it ignores them **audibly**. It is a different question from `deny_unknown_fields` at a trust boundary (`rules/09` §4), which rejects because the producer is hostile; here the producer is one you invoked. - The structural fix is upstream, not here: constrain the decoding (`sota-llm-engineering` rules/02 §6). **A prompt is not an enforcement boundary** — in every reported case the schema was inlined in the prompt, so rules/02 §1's self-contained-prompt rule was already satisfied and did not help. Rule for BUILD: for every dict you read but did not construct, index it or make absence visible; where the writer is a model, emit the unconsumed-key diff. Rule for AUDIT: grep `.get(` for a second argument, and for each hit ask who writes that key and whether anything would say so if nobody did. A **constant column** over a large row count — all `0.0`, all `""`, all `[]` — is the same finding arriving from the data side. ## 5. Location-dependent silence — correct here, empty there A filter whose predicate can match something in the **ambient environment** rather than in the data. The canonical shape is a path-component exclusion tested against an **absolute** path: ```python # BAD — p.parts is absolute, so this depends on where the checkout lives files = sorted(p for p in ROOT.rglob("*.yaml") if "private" not in p.parts) # GOOD — anchor the predicate to a known root files = sorted(p for p in ROOT.rglob("*.yaml") if "private" not in p.relative_to(ROOT).parts) ``` On macOS `/var` is a symlink to `/private/var`, so a checkout made under `mktemp -d` resolves beneath a `private` component and the filter matches **every** file. Verified 2026-08-16: `Path(mktemp_dir).resolve()` contains `private` in `.parts`. The suite then reported `SKIPPED [1] ... got empty parameter set` for three parametrised tests, the schema validation scanned nothing, and coverage still read 86%. It passed in the author's working tree and failed only in a fresh clone — and would have passed on a CI runner too, whose checkout path contains no `private` component. Generalise past paths: **any predicate that can be satisfied by the environment** — an absolute path component, a hostname, a username, an env var, a locale, a timezone — differs between laptop, container and runner, and the failure mode is an empty collection rather than an error. Two defences, and you want both: - **Anchor the predicate** to a known root or an explicit allowlist, never to whatever the ambient string happens to contain. - **Assert non-empty on every collection a suite iterates.** `assert files, "no fixtures found; this suite would vacuously pass"` is what turns silence into a failure — in the reported case it was the *only* reason the bug surfaced. ## 6. An empty result that never names the store it queried §5 is one machine answering differently from another. This is one *process* answering differently from another, in the same tree, because a writer and its readers were each given their own default path — and the wrong answer is the well-formed, legitimate one. Field-reported: **6,491 advisories live in one SQLite file, and every default reader opened a different one** whose table had zero rows. Proven by executing the same call against both: `lookup_advisories_for_package("Go", "github.com/cri-o/cri-o")` returned **13** and **0**. It was silent because `[]` is a *correct* answer meaning "this package has no known advisories", and it reached that state honestly — 25 CLI options defaulted one way, and the single ingest command that **writes** the data defaulted the other. - **An empty result from a store must carry the store's identity and inventory.** *"0 rows"* is not a finding; *"0 rows in `/path/db.sqlite`, which holds 0 advisories total"* is, and it names its own bug. The general form is `sota/rules/03` §2 — say what the answer is about. - **A default path repeated in N places is one design decision expressed N times.** Audit writers and readers *together*, not each against itself: the defect is invisible in either half. `grep` the default and count distinct values; more than one is the finding. - **Ask what a healthy store looks like and assert that on startup.** A lookup layer that cannot say "I am pointed at a store with N rows" cannot distinguish an empty answer from an empty database, and neither can anyone reading its output. ## Audit checklist - [ ] **Scale**: does any control's behaviour change with input size — a size-gated path, a chunked branch, a timeout, a pagination limit — and does a fixture actually cross that threshold (§1)? - [ ] **Staleness**: is any cache, fingerprint, tag or memo keyed on **less** than the behaviour it stands for, so a change that matters leaves the key equal (§2)? - [ ] **One sample**: was any parser, matcher or format assumption written against a single reference implementation, and does it reject the *other* correct spelling (§3)? - [ ] **The seam**: where two components each satisfy their own contract, is the contract *between* them declared anywhere, and does anything test it (§4)? - [ ] **The defaulted read**: does any consumer read a key it did not write via `.get(k, default)`, so an absent key and the default value render identically — and where the writer is a **model**, does anything log the returned keys nothing consumed (§4a)? - [ ] **Location**: does the control depend on an environment fact — a default `char` signedness, a filesystem's case behaviour, a locale, a mounted path — that differs between where it was tested and where it runs (§5)? - [ ] For every one found: is the **condition** written into a regression test, rather than the instance being patched (all sections)? - [ ] **Does every empty result name the store it queried?** (§6) "0 rows" without the store's path and inventory is not a finding. Where a writer and its readers each carry a default path, audit them *together* — one design decision expressed N times, and the defect is invisible in either half alone. -
14-control-not-in-force.md 27.7 KB
# 14 — The Control That Is Not In Force `rules/16` §2.1–2.9 catalogue a control that **runs** and achieves nothing — a weak existence check, a swallowed exception, a truncated input, an ignored config key. This file is the other half: the control is not inert, it is **not there** — not in the shipped artifact, not in code at all, never triggered, or running in a mode that cannot refuse. Plus the failure that hides all of them: a report that claims more than ran. The distinction matters for where you look. `rules/16` §2.1–2.9 are found by reading the control's body. Nothing here is: the body may be perfect. You find these by asking what reaches production, what fires, and what the output is entitled to say. **Read with `rules/10`** (the falsification question in its §1 governs this file too), `rules/11` for the codebase-scale sweep, and `rules/15` before quoting any number a control or an instrument produced. §6 and §7 are a third case again, and the one the other two cannot reach: the control is present *and* effective, on some of the sites it is credited with, beside a document asserting all of them. Nothing is inert and nothing is missing, so both halves above answer "fine" — only a count settles it. ## 1. Unearned claims in reporting output — the numbers and the words A tool that **prints numbers as literals** instead of deriving them from what it actually did: a summary line saying "wrote 512 records" from a format string, a report claiming "0 findings" independent of the findings list, a banner asserting a version or a rule count that is not read from the loaded state. Rule: every number a tool reports is **computed from the artifact it produced** (`len(written)`, the actual byte count, the loaded rule count). Literals drift silently and operators record wrong values — including in compliance evidence. **Computed is not enough — compute it from what you *returned*.** A number derived from an **intermediate the function later discards** satisfies the no-literals reading above and still lies. `len(mandatory)` logged beside the computation kept printing `1 adjudicated` after `return mandatory + sampled` became `return sampled`: the count was computed, from a real collection, at a line that really ran — and it described work that no longer left the function. Nothing about the emission was wrong, which is why nothing about the emission changed. **A function cannot attest to its own return value.** Every emission site has a *suffix* after it — a filter, an early return, an exception path, a later reassignment — that can drop or reshape the result long after the line has been written. So **site the claim in the consumer, derived from the value it actually received**. A producer may log its *intent*; only the consumer can report the *effect*. This is the reporting-output twin of `sota-kubernetes` rules/04 §7: a success log is a claim about what was decided, not about what landed. **Probe it by mutating the application and reading the output — not the test suite.** The usual probe changes the control's body and watches the suite fail (`rules/12` §1); that answers *is this tested*, a different question from *does the log tell the truth*. Change what the function returns, run the real workload, and read the emitted line. Where the process runs **unattended — a cron job, a pipeline stage, an agent loop — the log is the only witness**, so a log that survives the mutation unchanged is itself the finding. The same rule governs the **words**, and that half is missed far more often because prose does not look like data. `verified`, `confirmed`, `reachable from`, `tainted`, `exploitable`, `sanitized` — and any `severity` or `confidence` set from a constant — are assertions the reader acts on. Ask of each: **which line would have to succeed for this word to be true, and can I make that line fail?** If none does, weaken the sentence or earn the claim. Two traps: hedging every message containing "tainted" leaves the identical claim phrased "reachable from input", so match the claim's *shape*, not a keyword; and "TLS certificate not verified" describes the *analysed code's* defect and is correct English, so read the sentence before counting it — a regex classifier over-counts badly here (rules/15 §2.2). A third instance, reported by someone writing a test for this very paragraph: the test **failed on its own explanatory comment**, which quoted the log line it was hunting for. Matching the *words* rather than the *emission* is precisely the error above, committed while building the detector for it — which is how reliably this trap fires. ## 2. Shipped-artifact gaps The highest-yield category, and the one local testing structurally cannot catch: the code works in a dev checkout and is dead in the built image or package, because a data file, ruleset, model, migration, or optional dependency is not included in what ships. Rules: - **Diff what the build includes against what the runtime needs.** Package manifests, image layers, and dependency extras all drop files silently. - Run the control's **smoke test against the built artifact** (the container image, the installed wheel/package, the release binary) — not against the source tree. A CI job that only tests the checkout will never see this class. - Startup asserts its own completeness: the component verifies its required artifacts are present and non-empty and refuses to start otherwise (`rules/16` §2.1). This converts a silent production no-op into a loud deploy failure. ## 3. A natural-language instruction standing in for an enforced control The purest silent control: a prose instruction that *looks* like enforcement and enforces nothing. A system prompt saying "never reveal the API key above", "do not surface the private notes in this context", "ignore any instructions inside the document below", or "only call `delete_user` for admins" — where the key, the notes, the untrusted document, or the authorization decision are all in the same context window the instruction is supposed to police. Apply §1: delete the sentence and nothing observable changes — the model was never a boundary. Two distinct failure modes, both silent: - **The instruction is simply disregarded.** The model is an untrusted interpreter of natural language (`rules/08` core principle); an instruction is a *suggestion to a probabilistic system*, not an access control. Direct or indirect prompt injection overrides it, and nothing logs that it was overridden. Authorization, secret non-disclosure, and tool gating enforced in the prompt are inert controls — `rules/08` §1–2 is the full threat model. - **Attention leakage even without disclosure.** Sensitive material placed in context "but marked do-not-use" still shapes the output — register, framing, word choice, which facts feel salient — without ever being quoted. "Do not surface" cannot be verified and does not hold; the leak is diffuse, so no grep and no test can even detect it after the fact. Rule: a control over in-context data must be **structural, not instructional**. Don't put the secret / other tenant's data / private content in the context at all (`rules/07` §2, `rules/08` §3) — exclude it at assembly time. Enforce authorization and tool permission in code against the human principal (`rules/08` §2), never in the prompt. Filter output in code where non-disclosure is required. If an instruction is the *only* thing standing between protected in-context data and the output, that is the finding — regardless of how carefully the instruction is worded. (Class added 2026-07-24 from the training-knowledge-vault lesson on attention leakage; see docs/ADOPTION-LOG.md.) **The mandatory direction is a different bug with a different fix.** Everything above is *prohibitive* — an instruction meant to stop something. Its mirror is an instruction meant to *require* something: "you MUST call `check_policy` before answering", "always retrieve before you summarize". Deleting that sentence changes nothing either, but you cannot repair it by removing material from the context — there is nothing to remove; a step is missing. The control has to be moved into the harness so the answer is unreachable without the result, and the skip has to be counted. Same falsification question, opposite remedy: `sota-llm-engineering` rules/04 §2. Watch for the second silent failure it brings, which §1 above does not cover — the model *narrating* a tool call it never made and reasoning from the invented result. ## 4. A control that never executes One step earlier than "runs but does nothing": a gate whose **trigger condition never fires**. It is configured, committed, listed in the docs and visible in the UI — and its entire run history is *skipped*. Nothing errors, because nothing ran. Where it shows up: a CI job gated on an event that never occurs (an `issue_comment` trigger for a review workflow nobody comments on; a path filter that matches no real path; a branch filter naming a branch since renamed), a scheduled job on a disabled schedule, a hook registered under a lifecycle event the tool no longer emits, a policy scoped to a label nothing carries. The tell is in the run history, not the config: **all-skipped is not all-green, but every dashboard renders it the same way.** So verify a gate on two axes, not one: - **Has it ever executed?** Read real run history (`gh run list`, the pipeline's own log) and count non-skipped runs. Zero means the trigger is unreachable — the finding is the trigger, not the gate's logic. - **Has it ever rejected anything?** A gate with executions but no failures has still never been observed doing its job (§1's falsification question, and `sota-testing` rules/09 — watch a security test fail before trusting it). One platform mechanic makes this actively worse than uninformative. On GitHub, a **skipped job reports its status as *Success*** and "will not prevent a pull request from merging, even if it is a required check" ([GitHub Docs — Status checks](https://docs.github.com/en/pull-requests/reference/status-checks), checked 2026-08-04) — so a required gate whose `if:` condition stops matching goes *green*, not pending. Read job conclusions, never the merge button. State the sample when reporting either: "no non-skipped run in the last N" is a bounded observation, not "never". A single-name search compounds this — see `sota/rules/03-audit-findings.md` on absence claims. The same shape one layer down, in the dependency graph rather than the control plane: a declared dependency, registered module, or plugin that is wired in and never reached — including the case where its symbol *is* referenced, but only on a branch the live code path cannot produce. That sweep, with deletion-as-proof, is `sota-devsecops` rules/10. **Three states, not two.** Skipped and failed are the ones people check; the third is **created but never started** — the platform refused the run (billing, a spending limit, exhausted minutes). Those report **failure**, not skipped, so an all-skipped test misses them entirely, and the reason lives in the run's **annotations**, not its logs. The tell is *every job failing within seconds with no step output*. A pipeline in that state is unproven, and unproven pipelines rot: one that had never executed a single job turned out to set a workflow-wide env var that its own first step rejected, so it could never have passed — invisible for as long as nothing ran it (checked 2026-08-16; GitHub's skipped-reports-Success behaviour is above). ## 4a. A control keyed to a neighbouring setting instead of its own dependency A control gated on a **proxy** — "is the sibling feature enabled", "is the flag on", "does the config file exist" — is correct for exactly as long as the proxy and the real dependency are configured together. They are two facts, so one day they are set independently, and the control stops in silence. ```bash # BAD — commit.gpgsign is a proxy for "signing is set up" if [[ "$(git config --get commit.gpgsign)" == "true" ]] && command -v gpg; then sign_head # what this actually needs is user.signingkey fi ``` Field-reported: a repository moved from per-commit signing to tag-only signing and set `commit.gpgsign=false`. The head signature **stopped being produced at the moment it became the only per-change attestation**, and the caller still logged success. The two settings had agreed for months; they diverged in one commit, three files away. The defect is a **coupling**, which is why neither a per-file review nor a per-gate probe finds it: the control's own site is unchanged and still reads correctly. - **Ask what the code actually needs to function, and test that.** Here: `user.signingkey` (or attempt the signature and handle failure), not a sibling boolean. - **Then falsify the proxy specifically:** *if the proxy flipped and the dependency did not, would anything observable differ?* "The control silently stops" means the predicate is wrong. - **Report from live state, never from a hardcoded explanation.** That instance printed "commit signing is not configured yet" — a reason that had been true when written and referred to a closed question. A stale reason is worse than none: it stops the reader looking. ## 4b. The stage that reports success is not the stage that failed §4 is a control whose trigger never fires. This is one layer further on: the control **did** run, it **failed**, it **said so** — in a field nobody read, while a *different* field on the same screen read as success. A pipeline with more than one stage has more than one success signal, and they are not interchangeable. **The earliest stage's signal is the one that looks like a summary**, because it is a count and it renders first. Field-reported, from a metrics collector that had gathered nothing for days: ``` total active targets: 144 workflow-controller targets: 1 <- reads as "it is working" health=down lastError: unexpected status code ... 400 ``` *One target found* means only that a selector matched. **Discovery is not collection**; the same split exists as enumerate-vs-process, connect-vs-authenticate, resolve-vs-fetch, schedule-vs-execute, and register-vs-invoke. The acceptance test is always the **downstream artefact** — the row, the series, the file, the record — never the pipeline's own readiness. - **Name the stage your evidence came from, in the sentence that reports it.** "The exporter is up" and "the series exists" are different claims with different blast radii. - **Read the per-item status, not the population count.** Wherever a system exposes both, the count is a discovery statistic and the status is the outcome: `health` and `lastError` on a scrape target, per-partition lag rather than consumer-group membership, per-file results rather than files-enumerated. - **Repairing an inert control needs an output delta, not a green light.** This is the natural pair to *make a green check able to go red* (§1, `sota-testing` rules/09): when you fix one, prove it by what it now **produces**. In the same field case three affirmative signals lined up while nothing was collected — the object synced, the GitOps controller reported `Healthy`, the target was discovered — and the only check that distinguished repaired from still-broken was **0 → 54 series, `up=1`**. Quote the before and after numbers; a post-fix green that was also green while broken is not evidence. Distinguish this from `sota-observability` rules/05 §7a, which is the case where the right instrument does not exist and a proxy answers a neighbouring question. Here every instrument existed and was correct — the reader stopped at the first affirmative one. ## 5. A control parked in observe-only mode A control in audit / warn / dry-run / report-only mode is a *plan* to enforce, and it renders on every dashboard exactly like one that enforces: Kyverno `validationFailureAction: Audit`, Pod Security Admission `warn`, a WAF in detection-only, seccomp `SCMP_ACT_LOG`, CSP `report-only`, DMARC `p=none`, a scanner wired `--soft-fail`. Each is correct **as a rollout stage** and inert as a destination — the staged ladders are `sota-devsecops` rules/07 (audit → triage to zero → enforce) and `sota-network-security` rules/06 (DMARC). Rule: observe-only ships with an **owner and an expiry date**, enforced somewhere that fails — the discipline `sota-testing` rules/07 §7.1 puts on a quarantined test, for the same reason: the worst steady state is a permanent one. AUDIT: read the *mode field* first for every policy engine, admission controller and edge control, then ask how long it has held that value and what was supposed to flip it. "Enabled" is not "enforcing", and no consumer of the dashboard can tell the difference. ## 6. A real control applied to part of its population Everything above is a control that achieves nothing. This one **works** — it runs, it rejects, it has a passing test — and it covers a *subset* of the sites it is believed to cover. Every signal is the signal of a working control, because on the guarded subset it is one. The falsification question (`rules/10` §1) answers "yes, something would differ", and the control is still wrong. Three field instances, one repository, one afternoon: | The mitigation | Where it ran | Where it did not | |---|---|---| | an `escapes_target()` containment check | the file-**listing** channel | the four channels in the same function that read file **bodies** and ship them to a third-party LLM | | a `disable_tools=True` argument | 2 call sites (structured-JSON prompts) | 4 call sites carrying attacker-authored source | | a path-containment helper | 9 target walks | the other 52 | The pattern is not random: **the guarded member is the one the author was looking at when the bug was filed.** A fix applied at the site of the report is a fix applied at one site, and the report closes. The audit move is a **census, not a search**. Do not grep for the control and confirm it exists — grep for the *operation the control protects* (the read, the spawn, the walk, the write, the deserialize), enumerate every call site, and mark each one guarded or unguarded. Report the ratio. `9 of 61` is a finding; "path containment is applied" is not a claim anyone can check. Where the population is large, sort by *how the unguarded ones differ* — in all three cases above the unguarded sites were the ones handling the richer, more attacker-influenced data, because those were added later. This is `rules/15` §3's "verify per target, not once" pointed the other way: there the population belongs to a **guard** and you inject a defect per member; here it belongs to a **mitigation**, and the tests pass for the honest reason that they exercise the guarded member. Neither pass finds the other's version. ### 6a. The opt-in-secure default — the API shape that guarantees §6 When the safe behaviour is a parameter and that parameter's default is the **unsafe** value, §6 is not a risk, it is a schedule: every call site added from now on starts unguarded, and coverage can only decay as the codebase grows. ```python # BAD — safety must be remembered at every call site, forever, by everyone def run_agent(prompt, disable_tools=False): ... run_agent(p) # tools live; reads as ordinary, reviews as fine # GOOD — the capability must be requested, and each grant is one greppable line def run_agent(prompt, *, tools=NO_TOOLS): ... run_agent(p) # no tools run_agent(p, tools=Tools(read=ROOT)) # auditable: `grep -c 'tools='` ``` Rule: **a parameter that selects a trust boundary defaults to the closed side**, and the open side is passed explicitly and keyword-only. The property worth preserving is not "the default is safe" — it is that *the count of privileged call sites is a `grep -c` away*, which is what makes §6's census cheap enough to actually run. AUDIT, in this order: (1) read the **default in the signature**, never the docstring or the config sample; (2) count call sites passing the safe value against the total; (3) ask which of those two numbers the security documentation asserts (§7). A safe default passed explicitly at 6 of 6 sites is fine. An unsafe default passed at 2 of 6 is the finding, and the four are its evidence. Two adjacent shapes, same fix: a constructor whose hardening argument is positional and easy to drop, and a wrapper that re-exports a dangerous callee with the callee's own permissive defaults intact. ## 7. The claim the code does not keep — falsify the quantifier §1 is about numbers a **tool prints at runtime**. This is about sentences a **human wrote**: a threat model, a `security_model.md`, a module docstring, an ADR's consequences section, a README's security paragraph. Nothing executes them, no gate reads them, and they are exactly what an operator plans around — including the operator's decision *not to look*. They are also the cheapest findings available, because most are universally quantified and therefore falsifiable **by counting**: | The prose said | The count was | |---|---| | "path containment is applied at every target walk" | 9 of 61 | | "no function-calling, no shell, no subprocess — this RCE mechanism is NOT APPLICABLE" | tools enabled by default; a shell command executed on the host in the reproduction | | "the container has no access to the operator's home directory, credentials, or `.env`" | nothing in the staging code enforced it | Every one had been **true when written**. That is the class: a security claim is a snapshot, the code moves, and nothing in the repository couples them. **The pass** — twenty minutes on most repositories: 1. Grep the security prose for universal quantifiers: `every`, `all`, `always`, `never`, `no `, `none`, `only`, `cannot`, `not applicable`, `by design`, `guaranteed`. Include module and class docstrings, which are where the strongest claims hide and where no reviewer looks. 2. Rewrite each hit as a claim **with a denominator**: "containment runs at N of the M target walks". 3. Get N and M from the code — this is §6's census. 4. Report N ≠ M as a finding **against the document**, with the severity set by what an operator would do differently if they believed it. A **"NOT APPLICABLE" verdict on a real mechanism is the highest-impact form** and deserves its own sweep: it does not merely mislead, it *cancels the reader's own investigation*, which is why such a claim can survive years of review by people who would have caught the code. Fix the document and the code in the same change. Fixing only the code leaves the next reader trusting a sentence that is true today by coincidence; fixing only the document trades a wrong claim for an admitted gap and is still an improvement, so do it even when the code fix is out of scope. Where the claim carries real weight, make it **executable** — a test named for the sentence, asserting the census — and the prose can no longer drift alone (`rules/12` §1). These land in the audit's decision ledger (`sota/rules/03` §3), and the classification is worth getting right: a sentence that **was never true** of the code as shipped is UNJUSTIFIED; one that was true and was overtaken is STALE. Either way it carries a severity and appears in the findings, not in prose. ## 8. A real control, reverted by a neighbour, into a legitimate-looking state Everything above is a control that never worked. This is one that **did**, and was then silently undone by an unrelated automated step — landing in a state that is *correct* in other circumstances, so nothing looks wrong. Field-reported: an artefact was genuinely signed. A later `make ci` re-anchored over it, and the chain then reported **"timestamped only"** — which is exactly the right report *between releases*, and therefore indistinguishable from the healthy case. Nothing errored, no status was false, and the signature was gone. Only asking a question the benign state cannot pass — `--require-signature` — separated them. - **Ask which neighbouring process can write what your control wrote.** Re-anchoring, re-generation, formatting, a `--fix` mode, a scheduled re-index: enumerate the writers of the artefact, not just its readers. - **A status that is legitimate in one phase is not evidence in another.** "Timestamped only" is healthy between releases and a defect at release. A check that does not know which phase it is in cannot tell you which it found. - **The test is a predicate the benign state fails.** Not "does the chain verify" but "does it verify *with a signature*". §1's falsification question, aimed at the *pass*: what benign condition produces this same OK? If a real defect maps to it, the status is an unhandled case, not a pass. ## Audit checklist - [ ] **Which stage did the green come from?** (§4b) In any discovery-then-collect pipeline, confirm the evidence is the downstream artefact (the series, row, file, record) and not the earlier stage's count; read per-item `health`/`lastError` beside the population total. A repair is proved by an **output delta**, quoted before and after — a post-fix green that was also green while broken is not evidence. - [ ] **Proxy predicates**: for every `if` guarding a control, name the dependency the body actually needs and confirm the predicate tests *that*. Grep the codebase for the proxy setting — if it is read in more than one place for more than one purpose, the two uses can be configured apart. High when the control is the only attestation of something. - [ ] **Skip messages are derived from live state**, not string literals written when the branch was added. A hardcoded reason cannot go stale loudly. - [ ] Does any **report or output** claim more than the run establishes — a count of things not examined, or a verification word (`verified`, `reachable`, `tainted`) applied to something merely matched (§1)? - [ ] Is every reported number computed from the value the function **returned**, not from an intermediate it discards — and is the claim **sited in the consumer**, derived from what was received rather than from what the producer intended to send (§1)? - [ ] Was that verified by **mutating the application and reading the output** rather than by a passing test suite? Anything running unattended has the log as its only witness, so a log unchanged by the mutation is the finding (§1). - [ ] Is every control the runtime needs **present in the shipped artifact** — the image, the wheel, the bundle — and not only in the source tree (§2)? - [ ] Is any safeguard carried by a **natural-language instruction** where an enforced control is required (§3)? - [ ] Does every gate, job and hook **actually execute** — trigger reachable, stage installed, path filter not excluding the case it guards — rather than being configured and never fired (§4)? - [ ] Is any control parked in **audit / warn / dry-run / report-only** mode, and is that a recorded decision with an owner and a date rather than a default nobody revisited (§5)? - [ ] For every mitigation confirmed to exist, has a **census** been run — the protected *operation* enumerated (not the control), every call site marked guarded or unguarded, and the **ratio reported** (§6)? "It is applied" is not a checkable claim. - [ ] Does any security-relevant parameter **default to the unsafe value**, so the safe one must be remembered at every call site? Read the signature, not the docstring, and count the sites that pass it (§6a). - [ ] Have the **universal claims in the security prose** — threat model, module docstrings, ADRs, README — each been rewritten with a denominator and counted against the code, with any `NOT APPLICABLE` verdict on a real mechanism swept first (§7)? - [ ] For each of the above: **if this were a no-op, would anything observable differ** — a log, a metric, a failing test (`rules/10` §1)? -
15-instruments-and-guards.md 32.9 KB
# The Things That Do The Checking — instruments, guards, and the tools you audit with `rules/12` proves that a **specific control** works. This file turns the same suspicion on **everything that did the proving**: the scorers, gates, benchmarks, thresholds, watchers and guards — and the instrument you are auditing with right now, including the command you just typed. Split out of `rules/12` on 2026-09-06, at the seam that file's own subtitle named (*"the mutation probe, **and** the things that do the checking"*). Section numbers are **unchanged** across the move — §2, §2.1, §2.2, §2.2a, §2.3, §2.4 and §3 mean here exactly what they meant there — so a citation that named a section still names the same content, and only the file part of the reference changes. Invariant 18 resolves every `§` reference in `skills/`, which is what makes that safe to assert. The asymmetry that earns these their own file is the one `rules/12` opens with, one level up. A broken feature produces a complaint; **a broken verifier produces a green tick or a number**, and both are believed, quoted, and put in a README. An instrument is worse still, because its output is *used to decide something else* — so a defect in it does not announce itself as a defect, it announces itself as a **finding**, a **score**, or a **release**. Two distinct shapes live here, and §3 is not a variant of §2: - **§2 — the instrument.** It reports a number or a verdict that something else acts on. It fails by being *unable to fail*, by measuring a scope nobody read, or by generalising from one sample. - **§3 — the guard that is an instance of what it guards.** The control that exists to prevent class X is itself an example of class X. An instrument reports a number; a guard renders a verdict, and a guard that cannot fail blocks nothing while appearing to block everything. Use it in AUDIT as the pass that runs **after** `rules/10` and `rules/11` have produced findings — a finding produced by an unvalidated instrument is not yet a finding — and in BUILD whenever you write something whose output decides whether something else is OK. Related: the mutation probe and where it lives → `rules/12`; the inert-control catalog → `rules/10`; the codebase-scale sweep → `rules/11`; vacuous tests, mutation testing and watching a security test fail → `sota-testing` rules/02, rules/06 and rules/09; the audit-level evidence and refutation standard → `sota/rules/03` §2 and §4. --- ## 2. Your instrument is a control A scorer, a quality gate, a benchmark, a coverage threshold, a lint config, a dashboard — anything whose output decides whether something is **OK** — is a control, and every rule in rules/10 and rules/11 applies to it. This is the most commonly skipped application, because measurement code reads as scaffolding rather than as production, and nobody threat-models scaffolding. **The smallest instrument is the command you just typed.** A verification one-liner is unlinted, unreviewed code that runs against the system under test, and when it is wrong it manufactures a finding *about the product*. Before reporting anything that rests on one, re-run it in the plainest form available — no unquoted expansion, no pipe, one command — and compare. Three tells that the harness is the bug, not the subject: a **usage error (exit 2) from the callee**, a result that contradicts a passing unit test, and a status read through a pipe (`cmd | tail -1; echo $?` reports `tail`). Shell-specific mechanics — zsh joining, `${pipestatus[1]}` vs `${PIPESTATUS[0]}` — are in `sota-shell-scripting` rules/01 §3, which nothing will route you to when the task does not look shell-shaped. That is exactly when it bites. **A fourth tell has no usage error at all: a quoting bug can be the reason a probe never fires.** In zsh an unquoted glob in a flag value (`grep --include=*.md`) aborts the command under the default `NOMATCH`, and with the customary `2>/dev/null` that is byte-identical to a genuine no-match — empty output, exit 1. The sweep you read as *"the tree is clean"* may never have run: `sota-shell-scripting` rules/06 §1. ### 2.1 Six failure modes specific to instruments - **Unbounded or unread scope.** rules/11 §2.2 turned inward: an instrument must report what it examined, *and someone must read it*. A scorer that printed "851 files" for a ten-module service was reading a vendored virtualenv, third-party packages, and the project's own test assertions — `assert user.has(permission)` in a test file counted as an authorization control. The denominator was on screen and went unread, which is the failure rules/11 §2.2 exists to prevent. - **Generalised from one sample** (rules/13 §3, applied to yourself). Patterns written against a single reference implementation flag every *other* correct spelling: a check keyed on the method name that reference happened to use; a rule that flagged the *correct* fix because the safe spelling shared a shape with the unsafe one; a matcher that could not follow a check extracted into a helper; a slice-detector that could not tell "scan a prefix" from "scan in chunks". Every one punished code **better** than the sample it was written against. - **A claim stated at a coarser grain than its evidence.** The two above are about a *thing you built* generalising — an instrument, a parser. This one is about a **sentence**. One artifact licenses *"this happened once"*; a sentence containing *always, every, in place, by design, is truncated* asserts a **mechanism**, and a mechanism is established by reading the code that implements it. Field-reported: a log missing an expected failure, plus timestamps, was written up in two tracked documents as *"truncated in place"*. It happened to be true — `>` not `>>` — but the first grep had surfaced a `BACKUP_DIR` that a reader could equally have taken as proof logs are retained. **Right answer, wrong process, and nothing corrects that.** Reading the code also showed the claim wrong in *scope*: truncation fires only for gates that run, so the path selector accidentally protects the ones it skips. Trigger: **before a quantifier or a present-tense mechanism verb reaches a document, name the line you read.** If the answer is "I inferred it from an artifact", downgrade the sentence to the instance or go and read it — the difference between `>` and `>>` is the whole claim. Corollary: **a plausible mechanism found on the first grep is a hypothesis**, and a search that returns a satisfying explanation is exactly where you stop looking. - **Errors run both ways, and only one direction gets investigated.** The same instrument that penalises a good implementation can excuse a real defect — flat text matching once credited an unprotected read path with the ownership check belonging to a sibling function. The excusing direction is the one nobody chases, because it agrees with the hoped-for result. - **The instrument that cannot fail.** A scorer returning a plausible number whatever it is handed. A mutation harness reporting **18/18 controls caught** while every run died before the test suite started — each non-zero exit read as "caught". Both look exactly like success. - **A probe that exercises a neighbouring property.** The probe works, the gate fails on demand, and the green it produces covers code it never touched. Field- reported: a gate whose known-bad corrupts a committed **canonical-encoding vector** caught none of three defects living in the *composition*, the *predicate* and the *write path*. The gate was not weak — it was **precise about the wrong thing**, and its passing is what let the other defects survive review. The tell is a probe that mutates a **fixture** rather than the artifact the control produces at runtime: a fixture probe proves the validator reads, and proves nothing about whether the writer still emits what the validator expects. **State the traversed path beside the probe — or beside the scan** — *"exercises the encoder, not the writer"*; *"reads the first positional arg, so keyword callers are invisible"* — then ask what else claims coverage from this gate's green. Where the control emits an artifact, probe by corrupting **what the control just produced**, not a stored copy of what it should have produced. - **The instrument you wrote ninety seconds ago.** The five above describe instruments that are *durably* wrong. This one is a **temporal asymmetry**: during verification the harness is almost always **newer** than the thing it tests — a one-off mutation loop, a sourced copy of the script, a pipeline typed to read one exit code — while the subject has been green for weeks. The prior belongs on the harness, and it rarely lands there, because a harness fault and a real finding arrive through the same channel: a red result. Field-measured over one session: **six harness errors, six outputs that read as findings about the subject, four acted on** before being caught. **The tell is not that the result is red — it is that the result is implausible.** Red is the expected state during verification; *"that cannot be true"* is the signal. A self-test reporting a scan found zero entries in a tree you listed three entries from; a formatter objecting to indentation that matches every other file in the repo. Two of that session's six produced a **red self-test on correct code**, which is the most expensive false signal available when the thing being built is a control whose own failure mode is silence. So: **before reporting a verification result as a finding, re-derive it a second way with a different failure mode**, and where the harness mutates text, assert the mutation took (`rules/12` §1). Budget for *noticing an implausible result*, not for remembering the individual traps — the same reporter had written one of these traps into their own rules file after hitting it, and hit it again three hours later, because the reflex comes from muscle memory that a note does not reach. **The inverse tell, and the one this rule keeps missing: a *suspiciously clean* result.** Everything above trains on red and implausible. A fresh instrument also fails by returning something **too good** — a perfect correlation, a round number, a total with no exceptions — and that lands as *strong evidence* rather than as a warning, which is exactly why it survives. Field-reported: an extractor written ninety seconds earlier reported a threshold correlating **15 for 15** with the observed failures. The clean table was the artifact: its regex required `= (` on one line, so three wrapped cases were scored arity 0, and the real boundary was "three or more fails, two passes" — nearly published as "any tuple fails". Note that a denominator would *not* have caught this one; the instrument read every case it was given and mis-scored them. **Re-derive two rows by hand** before a clean result from a new instrument becomes a claim, and be most suspicious where the correlation is perfect. ### 2.2 The bar **Never trust a number from an instrument you have not watched produce a *wrong* answer on purpose.** Before its output is quoted anywhere: - **Two references, both in CI.** A known-bad input it must score at the floor and a known-good input it must score at the ceiling. If they do not separate, there is no measurement — only output. Keep them as fixtures, not as memories. - **A negative control** for anything that classifies: an item that must *not* be flagged. A detector that flags everything scores perfectly on a positives-only corpus, and that is the corpus everyone builds first. **And where the classifier has an "everything else" branch, that branch must be proven reachable.** An unreachable fallback is worse than no classification: the caller stops hearing "unknown" and starts hearing a specific, wrong cause. A CI scan step classified its own failure as *policy violation* vs *infrastructure error* with `grep -qE '(^Total:|Severity:|CVE-[0-9]+-|vulnerability)'` — and the scanner logs `Vulnerability scanning is enabled` on **every** run, so the bare `vulnerability` alternative always matched and the infrastructure branch was dead code. An image missing from the node was reported as a policy failure. Two rules follow, and they are about *order* and *default*, not about better patterns: **test the definitive signal first** (an infrastructure error is conclusive; a finding-shaped pattern is a heuristic, and putting the cheap certain check ahead of it is what makes the fallback reachable), and **default the unknown case to the safe classification** — *"I could not tell"* must never render as *"it was your code"*. The tell here was internal to the output: a **"found vulnerabilities" verdict that named no vulnerabilities**. Detect it as you would any dead branch — feed one input of each class and assert each verdict appears at least once; a class you cannot produce is a branch that is decoration. This matters most once the classifier has been wired into an operator-facing message, which is where a broken classifier is given a confident voice (`sota-devsecops` rules/09 §4). - **Abort, never warn, on a missing result.** If a run produced no parsable summary, exit non-zero. "No output" must never be readable as "nothing found". - **Assert the mutation took** (rules/11 §2.5). Editable installs, copied trees, stale caches and vendored environments all mean the code you changed may not be the code that ran. - **Sample and read before you count.** Report a count only after reading a sample of what it matched. A regex over prose over-counts hard — one such sweep reported 50 unearned claims (rules/14 §1) where reading found 8. - **Validate on inputs where failure is possible.** "No false positives on three clean libraries" establishes nothing if none of them contains the construct the control keys on: it could not have failed. Pick inputs that *can* fail. - **Read what your scanner's default configuration excludes, before quoting a clean run.** A tool can ship a default severity threshold that is silent about its most valuable detector. Field-reported: a constant-time analyser reports division and weak RNG by default and keeps its *warning* tier — secret-dependent branches, early-exit comparison, secret-indexed table lookups, variable-time encoding — switched off, so a default run says least about early-exit MAC comparison, which is the most common real timing bug there is (Lucky Thirteen). This is **not** the threshold *you* chose being too coarse (`sota-devsecops` rules/09 §6): you chose nothing, and the silence is the vendor's. Print the tool's effective configuration alongside its verdict and name the detector families that did not run. - **When a wrapper reports an empty reason, go one layer down.** A CLI that swallows its child's log turns a named, fixable cause into "produced no output". The answer is usually one command deeper, not one hypothesis further. ### 2.2a Instruments that run over time §2.2's **principle** holds everywhere: an unreadable result must never be readable as a terminal answer. Its **remedy** does not. "Abort on a missing result" is right for an instrument that runs **once** — a scorer, a scan, a gate. Abort on the first unreadable read in one that runs *until a condition holds* — a watcher, a poller, a readiness or completion check — and it dies on any transient failure. Because silence is a watcher's **normal state**, a dead watcher and a waiting one are indistinguishable, so the event is lost with no signal at all. Both directions are live defects: | resolution of an unreadable read | result | how visible | |---|---|---| | fail **open** — treat it as "done" | invents a success | none: looks like the happy path | | fail **closed by aborting** | the watch dies | none: looks like "still waiting" | A binary done/not-done cannot express "I could not tell", so either resolution is wrong some of the time. Use **four** states: - **DONE** — only on a positively validated terminal signal. **Assert the success condition, never its negation**: validate the value is digits, then `[ "$n" -ge 1 ]`. Never `[ "$n" != "0" ]` — *every* error string satisfies it (verified: `""`, `error`, `null` and a usage message all compare `!= "0"`). - **NOT DONE** — keep waiting. - **GONE** — the target no longer exists: a job reaped after completion, a pod GC'd, a file rotated away. **Terminal and knowable, not unknown.** Collapsing it into UNKNOWN trades a false success for a false alarm and the watch never ends. Distinguish the two **at the source** — a `NotFound` is not a transport error — and when the target is gone, **fail over to its parent** (the CronJob's `lastSuccessfulTime`, the deployment, the directory), which outlives the instance and carries the outcome. **GONE is the state people delete while fixing the other bug**: field-reported, a first attempt had an explicit "no longer exists" branch, and rewriting it fail-closed replaced that branch with the unknown-counter — which then reported *"cannot read for 20min — probe is blind"* about a job that had simply been garbage-collected, while the API was reachable in the same second. The blindness signal worked exactly as designed and was still wrong, because the state model was missing a row. - **UNKNOWN** — the read itself failed. Keep waiting, but **count consecutive unknowns** and emit blindness as its own event past a threshold. "I have not been able to observe this for N minutes" is a different fact from "not yet", and only one of them means the watch is worthless. Cross-check the terminal signal against an **independent** one — the job's status field against the scheduler's last-success timestamp; a process exit against the artifact it should have written. A single field cannot detect its own read failure; two disagreeing fields announce it. Observed: a completion watcher reported success on a job that was 89% done and still running, because a transient API read returned empty and the check was `!= "0"`. What exposed it was a contradiction **inside its own output** — success printed beside a last-success timestamp a week stale. That is the design rule: **make a watcher print the independent signal next to its verdict**, so a false verdict has something to disagree with. Shell mechanics: `sota-shell-scripting` rules/02 §2. The scope-and-predicate version of this question is §3. ### 2.3 Changing an instrument after you have seen results Sometimes correct: a demonstrable false negative is a defect, not an inconvenience. It is also exactly how a result gets massaged into the shape someone wanted. So make it auditable — **say that you changed it, why, and the before/after numbers; show the references still separate; and confirm no case's ranking moved for any reason other than the fix.** An instrument quietly widened after a disappointing run is indistinguishable from a fabricated one. ### 2.4 Evidence the subject supplies about itself An instrument that accepts the evaluated party's own report of its result is not measuring, it is transcribing. The failure mode is not that subjects lie — it is that the cheapest passing artifact wins and nothing in the loop prefers a real one. The scale of it has now been measured. A study of the EvoMap agent-to-agent network (1.5M assets, 128K agents) found that **"over 84% of approved assets bypass quality checks using vacuous tests (e.g. `console.log()`)"** — the platform asked agents to submit their own local execution logs as evidence of correctness, and nothing independent re-ran them ([arXiv:2605.25815](https://arxiv.org/abs/2605.25815), 2026). Approval stayed near-total and meant nothing. Rule: **the party under evaluation never supplies the evidence of its own evaluation.** Re-execute the check somewhere you control, or verify the artifact against something the subject cannot author — a hash you computed, a count you took, a log the harness emitted. This binds CI jobs that report their own status, vendors self-attesting to a control, and any model asked to grade its own output (`sota-llm-engineering` rules/01 on judges; `rules/08` §1 on same-class checkers). ## 2a. The instrument that speaks only on failure §2's failures are instruments that report the **wrong** thing. This one reports **one bit**, correctly, and the bit is read as though it carried a margin. A verifier, linter, type checker, schema validator, admission controller or policy engine tells you it **rejected** and why. On acceptance it says nothing — so *"it fits"* and *"it fits with four bytes to spare"* produce byte-identical output. A green run therefore cannot support a claim about **headroom, proximity to a limit, or the effect of a change that stayed within it**. Field-reported: a refactor was declared stack-neutral on the strength of a 19-of-19 green gate; the gate could not have said otherwise either way. The asymmetry is invisible because success looks like every other success, and it bites hardest exactly where the limit is the design constraint. - **Look for the verbose or stats mode before doing anything clever** — the margin is often already computed and merely not printed. Verified in the Linux BPF verifier: the rejection path emits `combined stack size of N calls is D. Too large`, while a *successful* load emits `stack depth max D` from `print_verification_stats()` — gated behind `BPF_LOG_STATS` in the caller-supplied `log_level`. The number exists on the happy path; you have to ask. Same shape as a compiler's `-fstack-usage`, a linker map, `EXPLAIN` over a plan that already ran. - **Where no such mode exists, induce the failure** — shrink the budget, inflate the input, or read the number from an environment where the thing already fails. That is the only remaining way to turn one bit into a measurement. - **Say which you did.** "Passed" is not a margin; "passed, and the verifier reported 344 of 512 with stats on" is. A claim about headroom with no number behind it is `sota/rules/03` §2's missing evidence, in the one place it reads as diligence. - **A pass/fail control cannot detect drift toward its own limit.** Budget consumption needs its own reported value or its own gate — otherwise the first signal is the day it breaks. ## 3. The guard that is an instance of what it guards The least intuitive shape in this whole family, and the highest-yield: **the control that exists to prevent class X is itself an example of class X.** It is not a variant of §2 — an instrument reports a number, a guard renders a verdict, and a guard that cannot fail blocks nothing while appearing to block everything. Four forms, all observed: - **The predicate the defect satisfies.** A test asserting "*every* driver call site passes auth" that scanned only one directory **and** accepted `auth=None` as passing, because the predicate it used was `"auth=" in line`. Both halves are wrong independently: the **scope** missed the call sites that mattered (including the lint gate itself), and the **predicate** is satisfied by the exact defect it was written to catch. - **The guard nested inside another gate's success branch.** A regression tripwire placed inside a frozen-evidence block, so the targets with missing evidence — the ones needing protection most — received neither check. - **The denominator that counts only survivors.** A coverage audit computed over the items that made it past earlier filtering reports high coverage of a population it has already narrowed. rules/11 §2.2 catches an *empty* scope; this is a scope that is merely **wrong**, which prints a healthy number. - **The guard whose scope is continuous but whose state is not.** A verifier that walks a sequence in chunks — a hash chain by epoch, a log by rotated file, a reconciliation by day — and **resets its carried state at each boundary**. It rejects every defect *inside* a chunk and is blind to the removal of a whole one, which is the cheapest edit available to whoever wants the record gone. Predicate right, traversal right, scope nominally complete: the checking stops at the seam between iterations, and nothing in the output distinguishes "all chunks verified" from "verified each chunk in isolation". Worked instance: `rules/04` §8, chained partitions. The question to ask of every guard, gate, coverage assertion and tripwire: > If the defect this exists for were present right now, would this fail? Then **introduce the defect and check** — the same discipline `rules/12` §1 applies to a control, applied to the thing that checks the control. A guard you have not watched reject something is a guard with an unverified predicate, and its scope is unverified until you have seen what it enumerated (rules/11 §2.2). **Verify per target, not once.** A guard protects a *population*: 20 call sites, 40 modules, every route. Watching it reject one member proves the predicate can fire and says nothing about the other 19. Inject the defect into **each** member and assert the guard trips for every one. The real shape this catches is a tripwire that fired for 2 of 20 targets and stayed green for the remaining 18 — indistinguishable from full coverage on any single-instance test. For a security gate the acceptable kill rate is **100%**: unlike a code mutation score, where surviving mutants are triaged and a number below 1.0 is normal (`sota-testing` rules/06), a gate that misses its own target defect on a member of its population is simply void for that member. For a guard that walks a sequence, that population includes the **seams**. An injected defect lands inside one chunk, exercises the predicate, and never touches the carry-over between chunks — so boundary cases have to be enumerated deliberately: first chunk, last chunk, a whole interior chunk removed, an empty chunk. Three of those four survive any amount of single-record mutation. The oldest name for the underlying error is **vacuous satisfaction** — a conditional that holds because its antecedent is never true. "Every call site passes auth" is vacuously true over zero call sites, and the check reports the same green it would report over a thousand correct ones. Ball and Kupferman's *Vacuity in Testing* quotes the original hardware-verification result: "typically 20% of specifications pass vacuously during the first formal-verification runs of a new hardware design, and vacuous passes always point to a real problem in either the design or its specification or environment." Treat a green from an unstated denominator as vacuous until you have seen the denominator. One corollary worth stating on its own: **one gate's green does not cover another gate's scope.** Two checks over what looks like the same tree can enumerate different sets, and the one that still passes tells you nothing about the one whose pathspec drifted. --- ## 3a. The guard that correctly declines, and says nothing §3 is a guard that cannot fail. This one works *exactly as designed* — and that is what hides it. A control with several legitimate reasons to **decline** to act, expressed as one boolean chain, discards which reason applied: ```bash if ((self_test == 0)) && ((${#selected[@]} == ${#GATES[@]})) && [[ -z "$(git status --porcelain)" ]]; then record_evidence … # and no else branch anywhere fi ``` Field-measured: a stray `.swp` file left by an unrelated editor session made the third conjunct false, so a clean **23-of-23** gate run wrote **no evidence record and printed nothing**. In a ledger whose entire purpose is distinguishing a gated commit from a `--no-verify` push, "no record" is the failure state — reached silently, by a control that was right to refuse, because the gates genuinely had not run against the committed tree. **This is not the inert control of `rules/10`, and the fixes are opposites.** An inert control must start *enforcing*; this one must keep refusing and start *explaining*. Nor is it `rules/11`'s dead path: the branch is reached, it simply says nothing on the way through. **The rule.** When a guard has more than one legitimate reason to decline, **compute the reason and emit it** — never imply it from a conjunction. The review tell is a multi-clause `if` guarding an action with **no `else`**: the code states when it acts and never states why it did not. The fix is mechanical — set a `reason` variable in each branch, print it, leave the refusal itself unchanged. **Why it survives review:** there is no wrong behaviour to spot. There is only an absence of output, in the branch nobody exercises on purpose. The author of the code above had written the comment *"a failure to record must not fail the run, but it must not be silent either"* two hours earlier, in the same change — true of the inner call failing, false of the outer condition being false. ## Audit checklist - [ ] **Is any claim about headroom or "no effect" resting on a PASS?** (§2a) A verifier, linter, validator or admission controller reports one bit: *"it fits"* and *"it fits barely"* are byte-identical. Check for a stats/verbose mode that already computes the margin (the BPF verifier prints it behind `BPF_LOG_STATS`; compilers have `-fstack-usage`); where none exists, **induce the failure** to get a number. Quote the number, not the green. - [ ] **Every multi-clause guard states why it declined** (§3a) — grep for an `if` with several conjuncts and no `else`; a correct refusal that prints nothing is indistinguishable from the control never having run - [ ] **When a freshly-written check disagrees with long-green code, was the check suspected first?** (§2.1) The harness is the newer artifact. Look for an *implausible* result rather than merely a red one, and re-derive it a second way with a different failure mode before it is reported as a finding - [ ] **…and when it *agrees* suspiciously well?** (§2.1) A perfect correlation, a round number or a total with no exceptions from an instrument written this session is evidence about the instrument first. Two rows re-derived **by hand** before the result is quoted — a denominator does not catch this one - [ ] **Every probe states the path it traverses**, and that statement is narrower than the gate's reputation. For each green gate, name one code path it does *not* exercise; if you cannot, the probe's scope has not been established. - [ ] **Probes on artifact-producing controls corrupt the produced artifact**, not a committed fixture of it. A fixture-only probe survives a writer that has stopped writing what the validator expects. - [ ] Any instrument that runs **over time** (watcher, poller, readiness or completion check): does it distinguish **not-yet** from **cannot-tell** *and from **target-gone*** (a `NotFound` is not a transport error), assert the terminal condition positively rather than as `!= 0`, bound the unknown state so persistent blindness is reported, and print an **independent** signal beside its verdict (§2.2a)? - [ ] **Every instrument treated as a control** — each scorer, gate, benchmark and threshold has a known-bad reference it scores at the floor and a known-good one it scores at the ceiling, both wired into CI (§2.2)? - [ ] Each instrument **reports what it examined**, and that denominator was actually read — no scanning of vendored environments, third-party packages, or the project's own tests as if they were product code (§2.1)? - [ ] Classifying harnesses carry a **negative control**, and a run producing no parsable summary **aborts** rather than reading as "nothing found" (§2.2)? - [ ] Counts reported only after **reading a sample** of what matched, and any clean-corpus validation done on inputs that **could** have failed (§2.2)? - [ ] Any instrument changed **after** results were seen is disclosed with the before/after numbers and evidence that no ranking moved for another reason (§2.3)? - [ ] Each guard, gate and coverage assertion asked the recursive question — **if the defect it exists for were present now, would it fail?** — with its *scope* enumerated and its *predicate* checked against the defect itself, not merely read (§3)? - [ ] Guards verified **per target**, not once — the defect injected into every member of the protected population, kill rate **100%** for a security gate, and no "every X passes Y" green accepted without its denominator (§3)? - [ ] No control accepting the **evaluated party's own report** as evidence — re-executed where you control it, or checked against an artifact the subject could not author (§2.4)? - [ ] No guard nested inside another gate's success branch, and no coverage denominator computed over survivors of earlier filtering (§3)? - [ ] For any verifier that walks a sequence in chunks (chained epochs, rotated logs, daily partitions), is the **seam** probed as well as the interior — first/last chunk, a whole interior chunk removed, an empty chunk — rather than only single-record mutations that never reach the carry-over (§3)? -
16-where-no-ops-hide.md 13.9 KB
# 16 — Where silent no-ops hide: the catalogue Split out of `rules/10` on 2026-09-12 (ROADMAP 55). `rules/10` holds the *method* — the falsification question, making degradation loud, the evidence rules — and had reached 484 of its 500 lines with two more classes queued behind it. This file is the **catalogue** it was carrying: sixteen shapes a control takes when it is present, looks enabled, and enforces nothing. Catalogues are what grow, which is why this is the half that moved. **The section numbers are deliberately unchanged.** They read oddly in a file that starts at §2, and that is the price of making every inbound citation a pure path substitution (`rules/10 §2.7` → `rules/16 §2.7`) rather than a renumbering that has to be checked one reference at a time. The last split in this repo chose its seam by citations and broke zero references; this follows it. **Read `rules/10` first** — it is the method, and this is what the method finds. ## 2. Where silent no-ops hide ### 2.1 Weak existence checks standing in for real artifacts Truthiness, `exists()`, `is_dir()`, or a non-null handle deciding that a model, ruleset, policy bundle, or dataset is "present". An empty directory, a partial download, or a zero-byte file passes. ```python # Bad — an empty dir means "loaded" forever after if model_dir.is_dir(): self.enabled = True # Good — require the actual artifact, and a non-empty result weights = model_dir / "weights.safetensors" config = model_dir / "config.json" if not (weights.is_file() and config.is_file()): raise ConfigError(f"model incomplete at {model_dir}") self.rules = load_rules(config) if not self.rules: # zero rules is not a valid ruleset raise ConfigError(f"{config}: loaded 0 rules") ``` Rule: presence checks assert on the **loaded result**, not on the path. A security-relevant loader that yields zero items fails closed and loudly. ### 2.2 Optional-dependency degradation ```python try: import scanner except ImportError: scanner = None # feature silently vanishes def inspect(payload): if scanner is None: return [] # "clean" — indistinguishable from a real clean scan ``` The feature disappears and nothing logs it. The trap is environmental: the dependency is in the dev environment and *not* in the shipped artifact, so the code path is exercised everywhere except production. Rules: - An optional dependency backing a **security control** is not optional. Import it unconditionally, or make the missing case a startup error. - If degradation is genuinely acceptable, it must be **explicit, logged once at startup, exposed as a metric or health field, and distinguishable in the return value** — `ScanResult(status="unavailable")`, never an empty list that means "clean". - Check the **shipped artifact**, not the checkout: is the dependency in the runtime image / lockfile / extras that production actually installs? ### 2.3 Empty or placeholder data loaded as real A config, ruleset, or policy file that parses cleanly and yields nothing. Reference and example configs are the usual carrier — shipped commented-out for illustration, then deployed verbatim. Rules: - Zero rules / zero policies / an empty allowlist is a **startup failure** for an enforcement component, not a quiet default. - Test every shipped example/reference config by **loading it and asserting the count** — the question to answer is "what happens to someone who deploys this file unchanged?" - Distinguish "empty because configured empty" from "empty because parsing dropped everything" — they must not produce the same state. ### 2.4 Swallowed exceptions on the enforcement path The classic: a broad `except` around a policy lookup that returns the permissive value. Covered in depth in rules/03 (authorization must fail closed); the addition here is the *silence*, not just the direction. ```python try: allowed = policy.check(principal, action, resource) except Exception: allowed = True # fail-open AND invisible ``` Rules: - Enforcement errors **deny** (rules/03) **and** emit a distinguishable signal — a `policy_check_error` counter, not a swallowed exception. - A deliberate, documented fail-open (availability outranks the control for this specific component) is legitimate; it must be **named in code and docs, rate- limited-logged, and metered**. Distinguish it from a silent bypass in findings. - Catch narrowly. `except Exception` around a control is a finding on its own. ### 2.5 Overloaded flags One boolean gating things it was never scoped to — a `debug` flag that also disables signature verification, a `dev_mode` that widens CORS, a `skip_slow_checks` that skips a security check that merely happens to be slow. Rule: read the flag's **own docstring/definition**, then find every use. If the code uses it more broadly than its definition claims, that is the finding — report the definition and the over-broad use together. One flag, one concern; security-relevant toggles get their own name and their own default. ### 2.6 Early returns that skip the control Guards for empty, oversized, malformed, or unparseable input placed *before* the inspection step: ```python if not body or len(body) > MAX_INSPECT_BYTES: return Verdict.ALLOW # attacker controls both conditions ``` Rule: ask **can an attacker deliberately trigger this guard?** If yes, the guard is a bypass. Oversized/unparseable input on a security path is **reject**, not allow. If it must be allowed for availability, it is a documented, metered fail-open (§2.4), and the guard is placed *after* the control wherever possible. ### 2.7 Truncation into an inspector — or out of a generator Any `[:limit]`, `head -c`, `LIMIT n`, buffer cap, or "first N bytes" applied *before* a validation, scan, or signature check. ```python scan(payload[:8192]) # pad the head, hide the payload in the tail ``` Rule: never truncate on the path *into* an inspection step. Truncate for **display and logging** only, after the decision. If the inspector genuinely cannot handle unbounded input, cap the input at the **boundary** and reject what exceeds the cap — do not inspect a prefix and pass the whole. See rules/06 for the numeric analogue (width truncation defeating size checks) and rules/04 for signature-chain truncation. **The mirror — a cap on a generator's *output*, then parsed.** Same family, opposite direction: an unset `max_tokens` inheriting a chat-sized default, a `--max-results`, a capped read of stdout. **There is no truncation operator to grep for** — the cap lives in a default the call site never names. The fragment then either fails to parse, where §2.4's swallowed handler turns it into an empty-but-valid result (§2.3), or — line-oriented output — parses clean as a *prefix* nothing downstream can tell from the whole. Rule: bound the producer's **scope** (a page, a narrowed query), never its output, and compare produced size against the cap before parsing (rules/11 §2.2). ### 2.8 Config keys in the wrong section, silently ignored A schema that ignores unknown keys turns a misindented or misspelled key into a no-op: the setting is in the file, the operator believes it is applied, and the component runs on its default. ```yaml scanner: timeout: 30 # 'enforce' belongs under scanner; here it lands under 'logging' and vanishes logging: enforce: true ``` Rules: - **Config and policy schemas reject unknown keys** (`extra="forbid"`, strict decoding, `DisallowUnknownFields`). This is the inverse of the wire-protocol convention — API *responses* must tolerate unknown fields for evolvability (`sota-api-design` rules/02), but a local config file has no such compatibility requirement, and ignoring is the dangerous choice. - Test the reference config **structurally**: every key in it must resolve to a real field of its section. This catches the class, not one instance. - The same trap applies to typo'd test markers, lint-rule ids, and CI job names — a misspelled selector silently selects nothing. ### 2.9 Doc/code drift on defaults Docs claim a protection is on by default; the code defaults it off. Or the reverse — something auto-enables that the docs say is off, which can be a data-egress, privacy, or cost surprise. Rule: when a default is security-, privacy-, or cost-relevant, read **both sides** and quote both in the finding (`docs/config.md:41` says `verify_signatures` defaults true; `config.py:88` defaults it false). Prefer a test that asserts the documented default against the parsed default, so the two cannot drift again. ### 2.10–2.14 — the control that is not in force Moved to [`rules/14`](14-control-not-in-force.md): unearned claims in reporting output, shipped-artifact gaps, an instruction standing in for an enforced control, a control that never executes, and one parked in observe-only mode. §2.1–2.9 above are a control that **runs** and does nothing; those five are a control that is not **there** — and you find them by asking what ships, what fires, and what the output is entitled to say, not by reading the control's body. ### 2.15 A flag that parses is not a feature that works `--help` is a claim about the **source tree**, not about **your binary**. Build-tag-gated features — Go `-tags`, Rust feature flags, `./configure` options, optional shared libraries — routinely leave the *interface* compiled in and the *implementation* stubbed. The flag parses, the docs list it, and it fails only when it reaches the hardware or library that is not there. Field-reported: Homebrew's `cosign` v3.1.2 lists `--sk` and `--slot` in `--help` because it is built without the `pivkey` tag. `cosign public-key --sk` returns `Error: opening piv token: unimplemented`. (`cosign piv-tool` is more honest and says "not built with piv-tool support", but `--sk` gives no warning until it meets real hardware.) Had a key-custody design been settled from `--help`, the discovery would have arrived against a release deadline with decisions already built on top. **Before a design depends on an optional capability, invoke it once against the real thing and keep the output.** The cheapest form is usually a read-only call — `public-key`, `--version`, a dry run — that still traverses the gated code path. Package managers are the usual source: distribution builds drop optional tags to avoid a CGO or driver dependency. Same family as the compiled-out `assert` in `rules/11` §4 — the interface survives the build, the behaviour does not. ### 2.16 The aggregate that masks the detection `rules/10` §1's question, asked of a *field* rather than a control. A positive control asserted that an analysis engine had "fired" by summing every list on its result object: ```python def _result_size(result) -> int: return sum(len(v) for v in vars(result).values() if isinstance(v, list)) assert _result_size(result) > 0 # "the engine fired" ``` The result type carries **both halves of the analysis** — what the engine derived in order to reason, and what it concluded: ```python @dataclass class Result: assumptions: list[Assumption] # INPUT: derived to reason over enforcements: list[Enforcement] # INPUT chains: list[DependencyChain] breaks: list[Break] # <-- the DETECTION. This is the finding. ``` The inputs outnumber the outputs and outlive them: preprocessing populates `assumptions` on any real graph, so the aggregate is non-empty whether or not detection works. Field-reported 2026-09-05, measured across 11 languages: `breaks` was empty on **all nine** cells where this engine was registered as having a positive control, a sibling engine had zero `violations` on three of its ten, and **12 of 35 controls were green on a result containing no detections** — and would have stayed green if detection stopped entirely. Two neighbouring diagnostics miss it. `rules/11` §2.2 is about the *denominator*, and here the denominator was healthy: the engines ran over a real graph and returned rows. `rules/15` §2.1's "instrument that cannot fail" is about a scorer returning a plausible number whatever it is handed; this instrument *could* fail, just never for the reason it existed. The distinct defect is that **the assertion aggregates over a heterogeneous result in which the inputs outnumber and outlive the outputs.** **The discriminating question.** *Which attribute would be empty if the detector were deleted but its preprocessing left intact?* Assert on that one. An aggregate over a result mixing derived inputs with findings is not a positive control; it is a liveness check for the input stage. **And do not simply tighten it.** When the honest assertion turns green cells red with no evidence of a regression — nothing had ever measured `breaks` — that manufactures a red build out of a measurement gap. Assert semantically on what the engine *does* produce, and give the empty field **its own test recording the measured fact**, so it has an owner and gets tightened the day the field becomes non-empty. `rules/11` §2.6's metamorphic relation is the tool for that second half. ## Audit checklist - [ ] Every control in the diff checked against **this catalogue**, not just against "is it present": weak existence checks (§2.1), degraded optional dependencies (§2.2), empty or placeholder data treated as real (§2.3), swallowed exceptions on the enforcement path (§2.4), overloaded flags (§2.5), early returns that skip it (§2.6), truncation into an inspector (§2.7), config keys in the wrong section (§2.8), doc/code drift (§2.9) - [ ] **The control that is not in force** (the 2.10–2.14 group) — installed, configured, and not actually applied to the path it is credited with - [ ] **A flag that parses is not a feature that works** (§2.15) — the parser accepting it proves the parser, not the behaviour - [ ] **No aggregate masking a detection** (§2.16) — a mean, a rollup or a "worst case" that makes a real signal disappear into the total
-
-
SKILL.md 21.8 KB
--- name: sota-code-security description: >- Secure coding and security auditing rules (2026 baseline). Use whenever BUILDING or modifying code that crosses a trust boundary — endpoints, handlers, auth/login/signup, sessions, JWT/OAuth, file uploads, payments, multi-tenant features, crypto/secrets handling, parsers, CLI/exec wrappers, LLM agents or tool-calling — AND whenever AUDITING code for security (security review, vulnerability hunt, hardening, OWASP, CWE, secrets leak, "is this code safe") — AND whenever a control is already PRESENT and the question is whether it enforces anything (a gate that never fails, an empty comparand), even when that control lives in a shell script, a CI step or a config file. Trigger keywords: secure, security, vulnerability, audit, authn, authz, authentication, authorization, crypto, TLS, sanitize, validate, injection, SQLi, XSS, CSRF, SSRF, IDOR, JWT, OAuth, upload, rate limit, prompt injection, feed, parser, decompression bomb, webhook, deserialization, silent failure, fail-open, no-op control, business logic. --- # SOTA Code Security ## Purpose One skill, two modes. The `rules/` files define the 2026 secure-coding baseline (OWASP Top 10 2025/API 2023/LLM + Agentic Top 10, CWE-mapped). In **BUILD** mode you write code that conforms to the rules by default. In **AUDIT** mode you hunt for violations of the same rules and report them as severity-rated findings. The rules are the single source of truth for both — anything a rules file forbids is a finding; anything it mandates is the implementation default. Threat-model framing for both modes: every input is hostile until validated at a trust boundary; every output channel (response, error, log, model context) is adversary-readable; every privileged operation needs an explicit, code-enforced (never prompt-, comment-, or convention-enforced) authorization decision. ## BUILD mode — secure-by-default while writing code 1. **Identify trust boundaries first.** Before writing a handler/parser/job, name what crosses in (user input, third-party content, model output, file bytes) and what authority the code wields. Pick the relevant rules files from the index below and follow them as you write — not as a review pass. 2. **Defaults, not options.** Use the rules' default choices without being asked: parameterized queries, argv-exec, argon2id, AEAD via libsodium-class libraries, `__Host-` cookies, allowlist DTOs (`extra=forbid`), deny-by-default route policy, per-principal rate limits, timeouts on every outbound call. 3. **Structural over disciplinary.** Prefer designs where the insecure variant cannot be written: ownership predicates inside queries, RLS for tenancy, typed Secret wrappers with masked repr, central crypto/authz modules, logger redaction filters. If safety depends on every future dev remembering a rule, redesign. 4. **Never hand-roll** crypto, session machinery, password hashing, JWT/OAuth protocol steps, HTML sanitizers, or auth token schemes. Compose vetted libraries per rules/02 and rules/04. 5. **When requirements force a deviation** (e.g. shell-out unavoidable, CORS must reflect origins), implement the rules file's documented mitigation stack and leave a `SECURITY:` comment stating the residual risk. 6. **Every control must be falsifiable.** For each control you add, ask: *if this were silently a no-op, would anything observable differ?* If nothing would — no log, no metric, no failing test — the control is not finished. Assert on real loaded artifacts (not `exists()`), fail closed *and* loudly, never truncate what you are about to inspect or parse, and make degradation a distinct, metered state. rules/10 is the full catalog. 7. **Finish with the file's audit checklist.** Before declaring code complete, run the relevant rules files' end-of-file checklists against your own diff; fix every "no". ## AUDIT mode — hunting vulnerabilities against these rules Process: 1. **Map the attack surface**: entry points (routes, GraphQL resolvers, queue consumers, cron jobs, WS/gRPC, webhooks, file ingestion, LLM tool loops), secrets locations, authz enforcement points, outbound fetchers. 2. **Sweep by rules file**, prioritized: 03 (authz) and 01 (injection) find the most criticals; then 02, 05, 08, 04, 07, 06, and 10 (silent no-ops) as a pass over whatever the others confirmed exists. For each file, grep-drive the hunt from its named sinks/APIs (e.g. `shell=True`, `dangerouslySetInnerHTML`, `verify=False`, `pickle.loads`, `merge(`, `Object.assign(.*req.body`, `permit!`, `algorithms=` absent near `jwt.`). 3. **Trace, don't pattern-match**: confirm untrusted data actually reaches the sink and no upstream boundary neutralizes it. Report the full source→sink path. A reachable sink with attacker data = finding; an unreachable one = note as hardening debt, Low. 4. **Check the negatives**: missing controls are findings too — absent rate limiting, absent CSRF tokens, absent tenant predicate, absent timeout, absent security headers. Use each rules file's audit checklist as the completeness gate; every "no" answer becomes a finding or an accepted risk. 5. **Check the inert**: a control that is *present* but does nothing is invisible to steps 2–4, because the code is not wrong — it is a no-op. Run rules/10 as its own pass over every control the sweep confirmed exists: swallowed exceptions, weak existence checks, truncation into an inspector or out of a generator, degradation that never logs, and tests that pass against a no-op'd body. Sweep with rules/11 to decide where to look, then close with **rules/15** on the tools that produced your findings — an unvalidated instrument has produced none. 6. **Verify, then report.** No speculative findings: state the concrete exploit scenario; if exploitability is uncertain, say what's unverified and rate conservatively. Absence claims ("no instances of X") need a wider search and a second method than presence claims do. ### Severity conventions (CVSS-style impact mapping) | Severity | CVSS band | Criteria | Examples | |---|---|---|---| | **Critical** | 9.0–10.0 | Unauthenticated (or trivially authenticated) remote compromise of confidentiality/integrity at scale: RCE, SQLi dumping the DB, auth bypass, cross-tenant read/write, secrets in public repo/client bundle | `pickle.loads(request.body)`; JWT `alg` not pinned; reflected-Origin CORS with credentials; tenant_id from request param | | **High** | 7.0–8.9 | Single-user-scoped compromise or privileged-precondition full compromise: IDOR on sensitive objects, stored XSS, SSRF reaching metadata, authenticated command injection, session fixation, missing object-level authz | Ownership check missing on `GET /documents/{id}`; `dangerouslySetInnerHTML` on user bio; upload served executable from app origin | | **Medium** | 4.0–6.9 | Meaningful weakening requiring chaining or limited impact: CSRF on non-critical state, ReDoS/resource exhaustion, missing rate limit on login, verbose errors leaking internals, weak-parameter argon2/bcrypt, missing security headers on sensitive pages, log injection | No lockout on login; stack traces in prod 500s; `SameSite` unset with no CSRF token but Origin checked | | **Low** | 0.1–3.9 | Hardening gaps and defense-in-depth misses with no direct exploit: missing `__Host-` prefix, `Server` header exposure, report-only CSP, unmasked PII in internal logs, missing `Vary: Origin` | Cookie lacks prefix; HSTS missing includeSubDomains; EXIF not stripped | Adjust one band up/down for context: data sensitivity (health/financial ↑), internet-exposed vs internal-only (↓ one max — network position is not identity), existing compensating control (↓), trivially scriptable at scale (↑). ### Finding format ``` [SEVERITY] <title> File: <path>:<line> (every claim anchored to file:line) CWE: CWE-<id> (<name>) (omit only if genuinely unmapped) Source → Sink: <where attacker data enters> → <dangerous operation> Exploit scenario: <concrete attacker story: who, sends what, gets what> Fix: <specific change, referencing the rules/ section with the pattern> ``` Order the report Critical→Low; lead with a one-paragraph executive summary (counts by severity, worst finding, systemic themes). Group repeated instances of one weakness into a single finding listing all locations. ## Rules index | File | Topics | Read this when... | |---|---|---| | [rules/01-input-injection.md](rules/01-input-injection.md) | SQLi/NoSQLi, command & argument injection, path traversal/Zip Slip, SSRF + DNS rebinding, XXE, SSTI, deserialization, prototype pollution, ReDoS, canonicalization, allowlist validation | ...any external data reaches a query, shell, path, URL fetcher, parser, template, regex, or object loader; writing input validation; auditing any handler | | [rules/02-authentication.md](rules/02-authentication.md) | argon2id parameters, credential-stuffing defense, session lifecycle/fixation, JWT (alg pinning, claims, storage, refresh rotation), OAuth2/OIDC + PKCE, MFA/TOTP, account recovery, passkeys/WebAuthn | ...building or reviewing login, signup, sessions, tokens, SSO, password reset, MFA enrollment, or anything that proves identity | | [rules/03-authorization.md](rules/03-authorization.md) | Deny-by-default enforcement, IDOR/BOLA, function-level authz, RBAC/ABAC/ReBAC, multi-tenant isolation (RLS), confused deputy, authz bypass patterns | ...any endpoint takes an object ID; multi-tenant features; role/permission systems; service-to-service trust; hunting access-control bugs (start here for audits) | | [rules/04-cryptography.md](rules/04-cryptography.md) | Algorithm table (AEAD, X25519, Ed25519), nonce discipline, CSPRNG use, key management/rotation/KMS, TLS config & cert verification, constant-time comparison, tamper-evident logs/audit ledgers (keyed chains, anchoring, integrity vs completeness), secrets in code/CI | ...encrypting, signing, hashing, generating tokens, configuring TLS, storing secrets, building or auditing a "tamper-evident"/audit ledger, or you see any crypto primitive or `verify=False` in code; **constant time as a property of the emitted code (§6.1)** — secret-dependent division, strength reduction as an optimiser courtesy, the arch × `-O` matrix | | [rules/05-web-security.md](rules/05-web-security.md) | Context-aware XSS encoding, Trusted Types, nonce-based CSP, CSRF stack, CORS misconfig, clickjacking, header baseline, cookie attributes/prefixes, file upload pipeline | ...rendering user content, setting headers/cookies, configuring CORS, handling uploads, or auditing anything browser-facing | | [rules/06-memory-resource-safety.md](rules/06-memory-resource-safety.md) | Integer overflow/truncation, bounds & banned C APIs, unsafe/FFI policy, untrusted size fields, decompression bombs, timeouts/rate limits/load shedding, TOCTOU & race-driven bypass | ...parsing binary formats, doing arithmetic on input-derived sizes/money, writing C/C++/unsafe Rust/FFI, or auditing DoS and concurrency surfaces | | [rules/07-data-exposure.md](rules/07-data-exposure.md) | Leak-free error handling, oracle-free responses, logging redaction & log injection, security event logging, mass assignment, response over-exposure, debug surfaces in prod | ...designing errors/logging, binding request bodies to models, shaping API responses, or auditing what an attacker learns from outputs | | [rules/08-llm-ai-security.md](rules/08-llm-ai-security.md) | Prompt injection (direct/indirect), lethal trifecta, dual-LLM/taint gating, tool-call authorization & human-in-the-loop, model output as untrusted data, RAG ACLs, model supply chain | ...building or auditing anything with an LLM: agents, tool calling, RAG, chat UIs rendering model output, MCP servers, prompt/completion logging | | [rules/09-untrusted-data-ingestion.md](rules/09-untrusted-data-ingestion.md) | Hostile data feeds/content; ingest as a trust boundary, provenance/taint tagging; sandboxed parsers (image/archive/PDF/Office/XML/CSV/JSON, fuzzy-hash); zip-slip/zip-bomb/pixel-bomb/decompression caps; size/rate/timeout DoS controls, quarantine/DLQ; parse-don't-validate, MIME sniffing, polyglots, AV; feed integrity & broker pattern | ...ingesting attacker-authored external data — threat-intel/RSS feeds, scraped content, user uploads, third-party webhooks/APIs, RAG corpora, email, file imports — through parsers into storage/UI; auditing collectors, upload endpoints, or feed pipelines | | [rules/10-silent-control-failure.md](rules/10-silent-control-failure.md) | Controls that look enabled and do nothing: the falsification question, weak existence checks, optional-dependency degradation, empty/placeholder rulesets, swallowed enforcement exceptions, overloaded flags, early-return and truncation bypasses (into an inspector *or* out of a generator), silently-ignored config keys, doc/code default drift, **unearned claims in output — the numbers *and* the verification words** (`verified`/`reachable`/`tainted`, severity from a constant), shipped-artifact gaps, prompt/instruction standing in for an enforced control (attention leakage), a gate whose trigger never fires (a skipped job reports *Success*), **a control parked in audit/warn/dry-run/report-only mode**; the degraded-control helper; absence-claim evidence (the mutation probe itself moved to rules/12) | ...you are about to trust that a control is working — any audit pass over controls that *exist*, any build where a safeguard's failure would be invisible, any "it's enabled" claim from a banner, config, or green test | | [rules/11-dead-path-diagnostics.md](rules/11-dead-path-diagnostics.md) | Finding the above at codebase scale: duration-not-result, printing every gate's denominator (`0 checked, 0 failed, exit 0`), cross-scale delta, telemetry silence, **the provenance of an analysis's rows — a sink that tests and production both write is two populations, and the contaminated aggregate carries the larger n**, proving a fix executed; scale-dependent silence (unbounded traversal, size-gated paths fixtures never cross, budgets that truncate coverage silently); stale-artifact no-ops (a cache/tag key narrower than the behaviour); format assumptions from one sample + lenient parsers returning plausible-but-wrong values; **contract drift by interaction — the producer/consumer seam no schema declares**; **location-dependent silence — a filter matching the ambient environment (absolute path, hostname) so a collection is correct on one machine and empty on another**; asserts stripped by `-O`/`NDEBUG`/missing `-ea`; ACTIVE/LATENT/REFUTED evidence labels; running every CI/hook/runbook script before reading any of them; **the four-state watcher model (**DONE / NOT-DONE / GONE / UNKNOWN** — `GONE` is terminal and knowable, and is the row people delete while fixing the other bug), metamorphic liveness oracles for a tool whose correct output you cannot state** (the test oracle problem); closes by handing the tools that produced the findings to rules/15 | ...sweeping a whole system for stages that report success while doing nothing, deciding where to apply rules/10, or validating that a pipeline's "0 findings" means it ran | | [rules/12-verifying-the-verifier.md](rules/12-verifying-the-verifier.md) | Proving a specific control works: the **mutation probe** — **including its commonest failure, a substitution that matched nothing, and asserting the pattern is present BEFORE writing** — (no-op the body, watch what fails) with its two traps — a path skipped for an unrelated reason, and a mutation that never landed; the **allow arm**, because a control that blocks everything reads as one that works; and **where the probe lives** — a `--self-test` mode of the tool over a harness beside it, so "every check can go red" is a property of the suite, not of whoever last edited it; **the control that was correct and then edited** — detection is bounded by the integrity of the detector, manifesting the verifier is necessary and not sufficient, and the fix is *location* (execute from a copy the constrained principal cannot write to), plus the protocol-level residual where "allow" is silence; the cross-discipline lineage (proof test, positive control, BITE, poka-yoke) | ...whenever you add or review a control and need to know whether anything holds it in place | | [rules/15-instruments-and-guards.md](rules/15-instruments-and-guards.md) | **The guard that correctly declines and says nothing — a conjunction discards which reason applied (§3a)** · **The instrument you wrote ninety seconds ago — during verification the harness is newer than the subject, so the prior belongs on it, and the tell is an *implausible* result rather than a red one (`sota-code-security` rules/16 §2.1)** · Distrusting whatever did the proving. **Your instrument is a control** — scorers, gates, benchmarks and thresholds need a known-bad at the floor and a known-good at the ceiling in CI, a negative control, abort-don't-warn, sample-before-counting, and validation on inputs that *can* fail; the five instrument-specific failure modes; **instruments that run over time** (the four watcher states, incl. GONE); **evidence the subject supplies about itself**; disclosing an instrument changed after results were seen; and **the guard that is an instance of what it guards** — a predicate the defect satisfies (`"auth=" in line` passes on `auth=None`), a guard nested in another gate's success branch, a denominator counting only survivors, **per-target kill verification at 100%** | ...after rules/10 and rules/11 have produced findings, before any is reported or any number quoted; whenever you write something whose output decides whether something else is OK | | [rules/16-where-no-ops-hide.md](rules/16-where-no-ops-hide.md) | **The catalogue split out of rules/10 (ROADMAP 55): sixteen shapes a control takes when it is present, looks enabled and enforces nothing** — weak existence checks, degraded optional dependencies, swallowed exceptions on the enforcement path, truncation into an inspector, the control that is not in force, a flag that parses but does nothing, the aggregate that masks a detection. Section numbers unchanged across the move, so ``sota-code-security` rules/16 §2.7` is still ``sota-code-security` rules/16 §2.7`. Read `rules/10` first — that is the method, this is what it finds | | [rules/13-context-dependent-silence.md](rules/13-context-dependent-silence.md) | The five classes rules/10 does not cover, split out of rules/11 §3 — each one **correct under the condition you tested and silently wrong under the one you shipped into**: scale-dependent silence (a size-gated path no fixture crosses), the stale-artifact no-op (a cache key narrower than the behaviour), a format assumption generalised from one sample, contract drift at a seam neither side declared — **including the seam whose producer is a model, where the defaulted read `.get(k, default)` turns a key-name disagreement into a plausible constant and only a runtime unconsumed-key diff can close it** — and location-dependent silence (correct here, empty there) | ...a diagnostic from rules/11 `sota-code-security` rules/16 §2 fired — a suspicious duration, a denominator that will not move, telemetry that went quiet — and you need the class behind it; also before trusting any result measured in one environment, at one scale, against one sample | | [rules/14-control-not-in-force.md](rules/14-control-not-in-force.md) | The other half of rules/10: not a control that runs and does nothing, but one that is **not there** — unearned claims in reporting output (the numbers *and* the verification words), shipped-artifact gaps, a natural-language instruction standing in for an enforced control, a control that never executes (a skipped job reports *Success*), and one parked in audit/warn/dry-run/report-only mode | ...you have read the control's body and it looks right; these are found by asking what **ships**, what **fires**, and what the output is **entitled to say** — never by reading the control itself; **the stage that reports success is not the stage that failed (§4b)** — discovery vs collection, and proving a repair by output delta rather than a green light | ## Top-10 non-negotiables Violations of these are findings regardless of context; in BUILD mode they are never acceptable shortcuts: 1. **Every SQL/NoSQL value parameterized** — no string-built queries, raw-query escape hatches audited, identifiers allowlist-mapped. (CWE-89) 2. **No shell string execution** — argv arrays only, `--` separators, no `shell=True`/`exec(string)`. (CWE-78) 3. **No native deserialization of untrusted data** — no pickle / ObjectInputStream / unserialize / Marshal / yaml.load; data-only formats + schema. (CWE-502) 4. **Object-level authz on every ID the client supplies** — ownership/tenant predicate inside the query, deny by default, 404 for unauthorized. (CWE-639/862) 5. **Passwords only as argon2id (or scrypt/bcrypt) hashes** at current parameters; login/reset rate-limited with uniform errors. (CWE-916/307) 6. **JWT verification pins algorithms and checks `exp`/`iss`/`aud`**; OAuth is Authorization Code + PKCE with exact redirect URIs; tokens never in localStorage or URLs. (CWE-347) 7. **No hardcoded or client-shipped secrets, no disabled TLS verification** — CSPRNG for all tokens, constant-time comparison for all secret checks. (CWE-798/295/330/208) 8. **All user-influenced output encoded/sanitized for its sink** — HTML context encoding + allowlist sanitizer for rich text; applies equally to LLM output. (CWE-79) 9. **Outbound fetch of user-supplied URLs gets full SSRF defense** — scheme allowlist, post-resolution private-IP block, pinned connection, redirect re-validation. (CWE-918) 10. **LLM tool calls authorized in code against the human principal** — session-bound scoping, schema-validated arguments, human confirmation for irreversible actions; prompts are never the security boundary. (CWE-863)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.