Security rules reference
June 12, 2026 · View on GitHub
One section per rule. Every rule is matched on the real syntax tree
(SwiftSyntax), never with regex over source text — a commented-out
SecTrustEvaluate can neither trigger nor suppress a finding, because
comments are trivia, not tokens.
Severity resolves in three steps: per-rule rules.<id>.severity >
global security.severity > the built-in default shown in each heading.
Suppress a single finding with // solid:ignore <reason> on the flagged line
or the line above — the reason is mandatory. Turn a rule off entirely with
disable: [ruleID]. Details in Suppressing and tuning.
A recurring theme below: each rule's Not flagged list is not a gap, it is the calibration. These rules were tuned against real codebases (Signal-iOS, isowords) until the false positives died; when a rule stays silent it is because the pattern is provably harmless or there is no proof of a problem. No proof, no noise.
Keychain
keychainAccessibleAlways (error)
Detects any reference to kSecAttrAccessibleAlways or
kSecAttrAccessibleAlwaysThisDeviceOnly — plain identifier match, anywhere
in the file.
Why it matters These protection classes keep the Keychain item decryptable while the device is locked. A stolen, locked phone — or any forensic tooling that talks to a locked device — can read the item. Apple deprecated both constants for exactly this reason.
Example
// Bad — readable while locked
let query: [String: Any] = [
kSecAttrAccessible as String: kSecAttrAccessibleAlways,
]
// Good — protected by the device lock, never migrates to another device
let query: [String: Any] = [
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
Not flagged Every other accessibility constant
(kSecAttrAccessibleWhenUnlocked, …AfterFirstUnlock, …). The rule reports
the exact identifier it found, so …AlwaysThisDeviceOnly is never misreported
as its prefix.
keychainMissingAccessibility (error)
Detects a SecItemAdd call whose query dictionary literal — written
inline, or assigned to a let that is visible in the same file — contains
kSecClass but no kSecAttrAccessible.
Why it matters Without an explicit accessibility attribute the item silently gets the OS default protection class. Maybe that default is fine for your item, maybe not — but nobody decided, and a reviewer reading the query cannot tell what protection the item actually has. Security-sensitive defaults should be visible in the code.
Example
// Bad — protection class left to the OS default
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecValueData as String: data,
]
SecItemAdd(query as CFDictionary, nil)
// Good — the decision is explicit
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
]
SecItemAdd(query as CFDictionary, nil)
Not flagged — every exemption here is about proof of the final dictionary contents:
- Dictionaries built dynamically or out of file
(
SecItemAdd(makeQuery() as CFDictionary, nil)) — no literal, no proof. vardictionaries — they can gainkSecAttrAccessibleafter the fact viaquery[...] = ..., so the literal is not the final contents.- Names bound to more than one literal in the file. Keychain wrappers
canonically declare
let queryin each method (save/find/delete); resolvingSecItemAdd'squeryto another method's literal would be wrong, so ambiguous names stay silent. - Queries carrying
kSecAttrAccessControl. It is mutually exclusive withkSecAttrAccessible(theSecAccessControlobject carries its own protection class) — adding the "missing" key would actually break the call. This is the most secure pattern; it satisfies the rule.
sensitiveDataInUserDefaults (warning)
Detects defaults.set(_, forKey: "literal") / setValue(_, forKey:) on a
UserDefaults-looking base (any base whose text contains defaults — covers
UserDefaults.standard and custom wrappers alike) where the literal key
matches a PII word: firstName, lastName, email, phone, ssn, dob,
homeAddress, userId, … Whole-word matching across camelCase and
snake_case, so user_email and userEmail both fire.
Why it matters UserDefaults is a cleartext plist on disk. It ships in device backups and is trivially readable from an unencrypted backup or any jailbroken device. Unlike a credential, storing a display name can be deliberate — hence warning, not error.
Example
// Bad — PII in a cleartext plist
UserDefaults.standard.set(user.email, forKey: "userEmail")
// Good — Keychain (or encrypted storage) for personal data
keychain.set(user.email, for: .userEmail)
Not flagged
- Keys owned by
tokenInUserDefaults—authToken,userTokenand friends are excluded at key level, so one store produces exactly one finding (the credential one, at error severity), never two. Bool/Intliteral values:defaults.set(true, forKey: ...)is provably a preference flag, not PII, whatever the key says.- Non-literal keys (
forKey: dynamicKey) — no proof of what is stored. - Bare
name/user/addresswords are deliberately absent from the list:displayName,userInterfaceStyle,serverAddress,fontNameare UI and infrastructure keys, not PII. Only qualified pairs (firstName,homeAddress,phoneNumber) are provably personal.
Crypto
insecureHash (error)
Detects CryptoKit's Insecure.MD5 / Insecure.SHA1 (bare or
module-qualified as CryptoKit.Insecure.MD5) and CommonCrypto's CC_MD5 /
CC_SHA1.
Why it matters MD5 and SHA-1 are not collision-resistant — attackers can craft two inputs with the same digest on commodity hardware. Anything security-relevant built on them (signatures, integrity checks, password hashing) is broken.
Example
// Bad
let digest = Insecure.MD5.hash(data: payload)
// Good
let digest = SHA256.hash(data: payload)
Not flagged SHA256 and stronger. There is no "looks like hashing"
heuristic — only the four broken symbols fire. Legitimate non-security uses
(ETag computation, cache keys, dedup against a legacy system) are exactly what
// solid:ignore <reason> is for:
let etag = Insecure.MD5.hash(data: body) // solid:ignore server requires MD5 ETags
hardcodedSecret (error)
Detects a string literal assigned to a secret-named identifier — apiKey,
password, token, secret, credential, passphrase, jwt, key —
matched whole-word across camelCase/snake_case (awsSecretKey, db_password
fire; monkey does not). Pure name heuristic: entropy analysis lives in
highEntropySecret.
Why it matters A secret compiled into the binary is not a secret. Strings
are extracted from any .ipa in seconds (strings, Hopper), and the value lives
forever in git history even after you "remove" it.
Example
// Bad — shipped to every device, forever in git history
let apiKey = "sk-live-abcdef123456"
// Good — injected at build/run time, or stored in the Keychain
let apiKey = ProcessInfo.processInfo.environment["API_KEY"] ?? loadFromKeychain()
Not flagged — the value filters are the heart of this rule. A secret-named variable stays silent when its value is identifier-shaped rather than secret-shaped:
- Placeholders and templates:
"changeme","YOUR_API_KEY","${API_KEY}","<insert>", the literal words"secret"and"password"themselves, and anything under 8 characters. - Interpolated strings (
"prefix-\(dynamic)") — not a hardcoded constant. - URLs (
"https://auth.example.com/token") — endpoints are not secrets. - Header names (
"X-Api-Key") — labels, not secrets. - Reverse-DNS / keypath shapes (
"com.app.lastSyncDate","user.profile.image") — UserDefaults keys, the most common iOS string idiom. Segments are capped at 20 characters, so JWTs (long base64url segments joined by dots) still fire. - Single pure-alpha words (
"tokenRefreshedNotification") — overwhelmingly identifiers. Accepted trade-off: an alpha-only password with no digits or symbols slips through; real secrets carry digit/symbol entropy. - Identifier-shaped phrases: words separated by
_ - /or space, each word letters plus at most 3 trailing digits. This clears Signal-style storage-key names ("kNSUserDefaults_FirstAppVersion","x-signal-checksum-sha256","v2/keys/signed","Screen Security Key") while keeping real keys flagged —"sk-live-abcdef123456"has a long digit run,"wJalrXUtnFEMI/K7MDENG/…"has interior digits; neither is word-shaped. - A value that echoes its own identifier
(
let kOWS2FAManager_…Key = "kOWS2FAManager_…Key") is a key name by construction, never a secret value.
highEntropySecret (warning)
Detects a long, high-entropy base64 or hex string literal anywhere in the code, whatever the variable is called. Two gates, because the alphabets cap entropy differently: base64-charset literals need length ≥ 20 and Shannon entropy > 4.5 bits/char; hex-charset literals (a 16-symbol alphabet maxes out at 4.0 bits/char) need length ≥ 32 — a 128-bit key — and entropy > 3.5.
Why it matters An embedded key with a bland name (let blob = ...) walks
straight past name-based detection. Entropy doesn't care what you called it.
Example
// Bad — 48 chars of base64, entropy ~4.6 bits/char: that is key material
let blob = "dGhpc2lzYXZlcnlsb25nc2VjcmV0a2V5MTIzNDU2Nzg5MA=="
// Good — fetched/derived at runtime, never a literal
let blob = try keyProvider.sessionKey()
Not flagged
- Alphabet/charset tables: a literal in which every character is distinct (like the base64 alphabet itself) is high-entropy by construction but never a secret — real keys drawn from a ≤ 64-symbol alphabet repeat characters at these lengths. (This gate exists because the rule once flagged the base64 alphabet inside its own source.)
- Low-entropy strings:
"aaaaaaaa…"(entropy 0),"deadbeefdeadbeef…"(English-y hex words sit around 2 bits/char, far under the 3.5 gate). - Anything outside the base64/hex charsets — an English sentence contains spaces and punctuation and never reaches the entropy check.
- Short strings (under the per-charset length gates).
Warning by default: test fixtures trip it legitimately — suppress those with
// solid:ignore test fixture.
Network
cleartextHTTP (error)
Detects Info.plist files (exactly that name, found under the lint roots)
where NSAppTransportSecurity.NSAllowsArbitraryLoads is true. This is the
one check that reads plists instead of Swift syntax trees.
Why it matters NSAllowsArbitraryLoads = true turns App Transport
Security off for every connection the app makes. Any request can silently
downgrade to cleartext HTTP, and anyone on the same network — coffee-shop
Wi-Fi, a hostile hotspot — reads and modifies the traffic.
Example
<!-- Bad — ATS off globally -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- Good — scope the exception to the one legacy domain that needs it -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>legacy.example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
Not flagged Per-domain exceptions (NSExceptionDomains) — that is the
fix, not the problem. Plists under exclude: paths, and corrupt or unreadable
plists, are skipped the same way unreadable Swift files are.
disabledTLSValidation (error)
Detects a urlSession(_:didReceive:completionHandler:) auth-challenge
handler that passes .useCredential a URLCredential(trust:) built from the
server trust without any SecTrustEvaluate* call
(SecTrustEvaluateWithError, SecTrustEvaluateAsync, … all count). Matching
is done on the body's tokens, so a commented-out // TODO: call SecTrustEvaluate neither suppresses nor triggers anything.
Why it matters Accepting the server trust without evaluating it trusts any certificate. Every MITM proxy with a self-signed cert — Charles on a test device, a corporate middlebox, an attacker on public Wi-Fi — reads and rewrites every byte of "TLS" traffic. This pattern is usually a dev-time hack that ships.
Example
// Bad — trust-all: any certificate is accepted
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
completionHandler(.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!))
}
// Good — evaluate, and be able to reject
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
let trust = challenge.protectionSpace.serverTrust!
guard SecTrustEvaluateWithError(trust, nil) else {
return completionHandler(.cancelAuthenticationChallenge, nil)
}
completionHandler(.useCredential, URLCredential(trust: trust))
}
Not flagged
- Handlers containing any
SecTrustEvaluate*token — system trust evaluation happens. - Handlers with a
.cancelAuthenticationChallengebranch: if the code can reject the challenge, validation logic exists somewhere — this is what keeps TrustKit-style wrapper pinning (guard pinner.validate(trust) else { ... }) silent. Deliberate trade-off: a trust-all with a decorative cancel path would be missed, but provability wins over sensitivity. - Functions that merely look similar — the rule requires the
urlSessionname and adidReceiveparameter.
httpURLLiteral (warning)
Detects any string literal starting with http://, minus a calibrated
set of hosts that are never real network endpoints. Complements the
cleartextHTTP plist check: ATS exceptions are useless without someone
actually writing http:// URLs.
Why it matters A cleartext URL is readable and modifiable by every hop on the path — and ATS will block the request in production anyway, so it is either a latent bug or evidence of an ATS exception that shouldn't exist.
Example
// Bad
let url = URL(string: "http://api.example.com/v1")
// Good
let url = URL(string: "https://api.example.com/v1")
Not flagged — these are identifier shapes or dev plumbing, not endpoints:
- Loopback and dev hosts:
localhost,127.0.0.1, IPv6 loopback in both forms (http://::1/health,http://[::1]:8080/x), and*.localmDNS hosts. Ports are stripped before matching. - XML namespace / DTD authority hosts (
www.w3.org,schemas.android.com,purl.org,ns.adobe.com,www.apple.com, …): these strings are opaque identifiers compared byte-for-byte, never fetched. - Any path ending in
.dtd— a DTD system identifier, not a live endpoint. - The bare
"http://"prefix literal, as used inurl.hasPrefix("http://").
Warning by default — dev tooling and tests legitimately reference http endpoints.
Auth
tokenInUserDefaults (error)
Detects defaults.set(_, forKey: "literal") / setValue(_, forKey:) on a
UserDefaults-looking base where the literal key matches a credential word:
token, jwt, password, secret, credential — whole-word, so
authToken, user_password, refreshToken and plain JWT all fire.
Why it matters A credential in UserDefaults sits in cleartext on disk and in every device backup. There is no legitimate variant of this — credentials belong in the Keychain, full stop. That is why this one is an error while its PII sibling is a warning.
Example
// Bad — cleartext on disk, in backups
UserDefaults.standard.set(jwt, forKey: "authToken")
// Good
keychain.set(jwt, for: .authToken)
Not flagged
- Bare
authis deliberately not in the word list: it overwhelmingly names preference flags (biometricAuthEnabled,requireAuthOnLaunch), not credentials. Real credentials still match via their own words —authTokenfires ontoken. Bool/Intliteral values —defaults.set(true, forKey: "biometricAuthEnabled")is provably a flag, whatever the key says.- Non-literal keys (
forKey: dynamicKey) — no proof.
Custom wrappers are covered: any base containing defaults
(appDefaults.set(...)) matches, not just UserDefaults.standard.
biometryNoErrorHandling (error)
Detects canEvaluatePolicy(_, error: nil) where the result is used —
in an if, a guard, or bound to a variable.
Why it matters The error out-parameter is the only signal explaining
why biometrics are unavailable: lockout after failed attempts, nothing
enrolled, passcode not set. Passing nil and branching on the Bool collapses
all of those into "no", and the user gets a dead button instead of "Face ID is
locked — enter your passcode".
Example
// Bad — every failure reason becomes a silent false
if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) { ... }
// Good — lockout and not-enrolled get their own handling
var error: NSError?
if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
...
} else if let error { handleBiometryError(error) }
Not flagged A bare statement call that discards the result too:
context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil)
let type = context.biometryType
This is Apple's documented idiom — canEvaluatePolicy must be called before
reading biometryType. No auth decision is made, so there is no ignored
failure path. (Calibrated against Signal-iOS, which uses exactly this.)
Binding the result (let ok = ...) with error: nil stays flagged.
biometryNoFallback (warning)
Detects any use of .deviceOwnerAuthenticationWithBiometrics outside a
case pattern.
Why it matters The biometrics-only policy has no passcode fallback: after
a few failed Face ID attempts the user is locked out of the feature entirely.
.deviceOwnerAuthentication falls back to the device passcode automatically.
Example
// Bad — failed Face ID is a dead end
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: r) { ok, err in }
// Good — the system offers the passcode after biometric failure
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: r) { ok, err in }
Not flagged case .deviceOwnerAuthenticationWithBiometrics: in a switch —
a case pattern is provably handling a policy someone else selected, not
selecting one. Plain == .deviceOwnerAuthenticationWithBiometrics comparisons
are not provably harmless and stay flagged. Warning by default: apps that
implement their own fallback UI suppress the call site with
// solid:ignore custom PIN fallback.
Logging
publicPIIInLog (error)
Detects an os_log/Logger string interpolation that combines a PII-named
value with privacy: .public — e.g. \(email, privacy: .public). For member
accesses, the last component is what is evaluated: session.authToken
fires on authToken.
Why it matters os_log redacts dynamic values by default — privacy: .public is an explicit opt-out. A public PII interpolation puts the value in
the unified log, readable in Console.app and shipped inside every sysdiagnose
the user sends to anyone.
Example
// Bad — redaction explicitly defeated for PII
logger.info("user: \(email, privacy: .public)")
// Good — redacted in the log; or use a non-identifying hash
logger.info("user: \(email, privacy: .private)")
Not flagged privacy: .private and interpolations with no privacy
argument (the default is private — already redacted). Non-PII names
(\(requestCount, privacy: .public)) — publishing a counter is the entire
point of the API.
printSensitiveData (warning)
Detects print / debugPrint / NSLog / dump calls where what is
actually printed has a sensitive name. "Actually printed" means: a bare
identifier (print(password)), the last component of a member access
(print(viewModel.username)), or those same shapes inside string
interpolation (print("logging in \(userEmail)")). Matched against the
combined PII + secret word lists.
Why it matters Unlike os_log, print and NSLog have no privacy
redaction, ship in release builds, and end up in sysdiagnoses and crash-report
attachments. A token printed once is a token leaked.
Example
// Bad — the token lands in the console and in sysdiagnoses
print("u: \(session.authToken)")
// Good — os_log redacts by default
logger.debug("user session refreshed")
Not flagged — the calibration here is what makes the rule usable:
- Bases of member accesses:
tokens.countprints a count,token.kindprints a kind — only the last component is evaluated, so neither fires whilesession.authTokenstill does. - Bare
keyis excluded from the word list:keyWindow,keyPathandfor (key, value) in dictloops are ubiquitous Swift idioms carrying no secret. Multiword forms (apiKey,storeKey) still fire. - Static string content never triggers — only interpolated expressions are
inspected, so
print("press any key")is silent. - Function-call results and array literals as arguments are ignored — no identifier to judge.
Suppressing and tuning
solid:ignore
let etag = Insecure.MD5.hash(data: body) // solid:ignore server requires MD5 ETags
- Works on the flagged line or the line above; the reason is mandatory — a
bare
// solid:ignoresuppresses nothing. - The directive must start the comment body; a mid-comment mention does not count.
- Granularity is the enclosing statement: a directive above (or at the end of) a multi-line statement suppresses findings anywhere inside that statement. There is no file-level or block-level ignore — that is deliberate; broad waivers belong in config, with review.
disable and severity
security:
enabled: true
severity: warning # optional: override every rule's default
disable: [highEntropySecret]
rules:
printSensitiveData:
severity: error # per-rule override beats the global one
Precedence: rules.<id>.severity > security.severity > the rule's built-in
default (shown in each heading above). Unknown rule IDs anywhere in the config
are rejected before linting — a typo cannot silently disable nothing.
Baseline — adopting on a legacy codebase
Don't fix 200 findings on day one. Snapshot them, then fail only on new ones:
solid-like-a-rock --write-baseline .solid-baseline.json Sources # once, commit the file
solid-like-a-rock --baseline .solid-baseline.json Sources # CI: new findings only
A finding's identity excludes the line number, so editing code above a baselined finding does not resurface it as "new". The baseline file is plain, sorted JSON — diff-friendly and safe to commit.