# Aere crypto inventory A cryptographic inventory of source code. It reads a directory, finds where cryptography is used (algorithms, key sizes, curves, modes, TLS settings, JWT algorithms, key and certificate files), classifies each use by its exposure to a quantum computer, suggests a migration target, and writes the result as a **CBOM** (Cryptography Bill of Materials) in **CycloneDX 1.6** format. It is a single Node.js script with no dependencies: only built-in `node:*` modules. Nothing from the scanned tree is executed, and no network request is made. ``` node tools/crypto-inventory/inventar.mjs scan ./my-service --out cbom.json --summary ``` ## What it detects Detection is pattern-based on the source text, after comments (and Python docstrings) are removed. An API name that appears only inside a comment or inside a string literal is not reported as a use. | Language | What is recognized | |---|---| | JavaScript / TypeScript | `node:crypto` (`createHash`, `createHmac`, `createCipheriv`, `createSign`/`createVerify`, `generateKeyPair(Sync)` for `rsa`, `rsa-pss`, `dsa`, `ec`, `ed25519`, `x25519`, `dh`, `ml-dsa-*`, `ml-kem-*`, `slh-dsa-*`, `createECDH`, `getDiffieHellman`, `publicEncrypt`, `pbkdf2`, `hkdf`, `crypto.sign`/`verify`, `createPrivateKey`/`createPublicKey`, `X509Certificate`); WebCrypto `subtle` (RSA-OAEP, RSASSA-PKCS1-v1_5, RSA-PSS, ECDSA, ECDH, Ed25519, X25519, AES-GCM/CBC/CTR/KW, HMAC, HKDF, PBKDF2, `digest`); TLS options `minVersion`, `maxVersion`, `secureProtocol`, `ecdhCurve`; libraries from imports: `ethers`, `web3`, `viem` (secp256k1 ECDSA), `secp256k1`, `elliptic`, `node-forge`, `jsonwebtoken`/`jose` (JWS algorithms), `@noble/curves`, `@noble/post-quantum`, `@noble/hashes`, `crypto-js`, `tweetnacl`/libsodium, `node-rsa`, bcrypt/argon2, `@signalapp/libsignal-client` (`PrivateKey.generate`, `IdentityKeyPair.generate`, `KEMKeyPair.generate`, `hkdf`, `Aes256GcmSiv.new`, `PreKeyBundle.new` / `processPreKeyBundle`, anchored on the package import) | | Python | `cryptography.hazmat` (`rsa`, `ec`, `dsa`, `dh`, `ed25519`, `x25519`, `padding`, `hashes`, `algorithms`/`modes`, AEAD classes); PyCryptodome (`Crypto`/`Cryptodome`: `RSA`, `DSA`, `ECC`, ciphers and modes, `PKCS1_OAEP`, `pss`, `DSS`, hash `.new`); `hashlib` (including `hashlib.new` and `pbkdf2_hmac`); `hmac` with `digestmod`; `ecdsa`; PyJWT / python-jose algorithms; `oqs` (liboqs-python); `ssl` protocol constants and `TLSVersion` | | Java | `KeyPairGenerator`, `Signature`, `Cipher`, `KeyAgreement`, `MessageDigest`, `Mac`, `KeyGenerator`, `KeyFactory`, `SecretKeyFactory`, `KEM`, `SSLContext` `getInstance("...")`; `ECGenParameterSpec`, `NamedParameterSpec`; `setEnabledProtocols`; JSSE system properties (`jdk.tls.namedGroups`, protocol lists); Bouncy Castle imports and post-quantum parameter sets (`org.bouncycastle.pqc`, `MLKEMParameters`, `MLDSAParameters`, `SLHDSAParameters`, `FalconParameters`, ...); the libsignal Java/Android API (`org.signal.libsignal`, anchored on its import or package: `ECKeyPair.generate`, `IdentityKeyPair.generate`, `KEMKeyPair.generate(KEMKeyType.KYBER_1024)`, `HKDF.deriveSecrets`, `new Aes256GcmSiv` / `Aes256GcmEncryption` / `Aes256Ctr32`, `new PreKeyBundle`) | | Go | imports of `crypto/rsa`, `crypto/ecdsa`, `crypto/elliptic`, `crypto/ecdh`, `crypto/ed25519`, `crypto/md5`, `crypto/sha1`, `crypto/des`, `crypto/rc4`, `crypto/mlkem`, `crypto/tls`, `golang.org/x/crypto/...`, go-ethereum `crypto`, CIRCL ML-KEM/ML-DSA; calls with parameters (`rsa.GenerateKey` size, `ecdsa.GenerateKey` curve, `hmac.New` hash, `mlkem.GenerateKey768`); `tls.Config` `MinVersion`, `MaxVersion`, `CurvePreferences` (including `tls.X25519MLKEM768`) | | Rust | `use`/`extern crate` resolution of names to crates; static calls `Type::method` (`Sha256::new`, `SigningKey::generate`, `Aes256Gcm::new_from_slice`, `RsaPrivateKey::new(rng, 2048)`, `MlDsa65::key_gen`, `MlKem768::generate`); generic parameters (`Hmac::`, `Hkdf::`, `type HmacSha256 = Hmac`, `SigningKey::`); path functions (`blake3::hash`, `pbkdf2::pbkdf2_hmac::`, `pqcrypto_kyber::kyber1024::keypair`, `rustls::crypto::ring::default_provider`); `ring` constants (`aead::AES_256_GCM`, `signature::ED25519`, `agreement::X25519`); `jsonwebtoken::Algorithm::*`, `oqs::Algorithm::*`, libsignal `kem::KeyType::Kyber1024`; RustCrypto modes (`ecb::Encryptor`, `cbc::Decryptor`); `snow` Noise patterns (`Noise_XX_25519_ChaChaPoly_SHA256`); `openssl` and the BoringSSL bindings `boring` / `boring-signal` with the same API (`MessageDigest::*`, `Cipher::*`, `Rsa::generate`, `EcKey::generate` + `Nid`, `PKey::generate_ed25519`, `SslConnector::builder`, `set_min_proto_version`); `secp256k1` `.sign_ecdsa`/`.sign_schnorr`; libcrux (`libcrux_ml_kem::mlkem1024::generate_key_pair`, the pre-standard `kyber768` / `kyber1024` modules, `libcrux_ml_dsa::ml_dsa_65::sign`); `x25519_dalek::x25519(...)`; `HmacReset`; a mode type brought in by name (`use cbc::Decryptor; Decryptor::::new`); the hybrid Noise pattern `Noise_NKhfs_25519+Kyber1024_...` as one hybrid key exchange; a name brought in by a glob import (`use libsignal_protocol::*`) is resolved by trying the glob-imported crates in order | | Swift | names are resolved through `import`: CryptoKit / swift-crypto (`SHA256.hash`, `SHA384`, `SHA512`, `Insecure.MD5`, `Insecure.SHA1`, `HMAC`, `AES.GCM.seal/open` (key size from a `SymmetricKey(size:)` on the same line), `AES.KeyWrap`, `ChaChaPoly.seal`, `Curve25519.KeyAgreement.PrivateKey()`, `Curve25519.Signing.PrivateKey()`, `P256/P384/P521.Signing` and `.KeyAgreement`, `HKDF.deriveKey`, `SymmetricKey(size:)`, `_RSA` from `_CryptoExtras`); CommonCrypto (`CC_SHA256`, `CC_MD5`, `CCCrypt` / `CCCryptorCreate` with `kCCAlgorithm*`, `kCCOptionECBMode`, `kCCKeySizeAES*`, `CCHmac` with `kCCHmacAlg*`, `CCKeyDerivationPBKDF` with `kCCPRFHmacAlg*`); Security.framework (`SecKeyCreateRandomKey` with `kSecAttrKeyTypeRSA` / `kSecAttrKeyTypeECSECPrimeRandom` and `kSecAttrKeySizeInBits`, read from the call or from an attribute dictionary defined in the same file; `SecKeyCreateSignature` / `SecKeyVerifySignature` / `SecKeyCreateEncryptedData` / `SecKeyCreateDecryptedData` with `.rsaSignatureMessagePKCS1v15SHA256`, `.rsaSignatureMessagePSSSHA256`, `.ecdsaSignatureMessageX962SHA256`, `.rsaEncryptionOAEPSHA256`, ...; `SecTrustEvaluateWithError` / `SecPolicyCreateSSL` as certificate-chain validation); the client's TLS (`tlsMinimumSupportedProtocolVersion` / `tlsMaximumSupportedProtocolVersion`, `sec_protocol_options_set_min/max_tls_protocol_version`, `NWProtocolTLS.Options()`); CryptoSwift (`AES(key:blockMode:)`, `SHA2(variant:)`, `.md5()`, `HMAC(key:variant:)`, `PBKDF2`, `ChaCha20`, `Poly1305`, `RSA(keySize:)`, `Blowfish`); swift-sodium / Clibsodium (`sodium.box`, `.sign`, `.keyExchange`, `.secretBox`, `.aead.xchacha20poly1305ietf`, `.pwHash`, `.genericHash`, `crypto_box_*`, `crypto_sign_*`, `crypto_aead_*`, ...); the libsignal Swift API (`import LibSignalClient`, or `import SignalFfi` and the `Sources/LibSignalClient/` target directory for the library's own sources: `IdentityKeyPair.generate()`, `PrivateKey.generate()`, `KEMKeyPair.generate()` (Kyber-1024: the Swift API takes no key type, the bridge generates `kem::KeyType::Kyber1024`), `KEMPublicKey(...)`, `Aes256GcmSiv(key:)`, `Aes256GcmEncryptedData`, `Aes256GcmEncryption` / `Aes256GcmDecryption`, `Aes256Ctr32`, `hkdf(...)`, `PreKeyBundle(...)` / `processPreKeyBundle(...)`) | | Kotlin | the Java rules with Kotlin syntax (no `new`, no `;`, `val`/`var`, `arrayOf(...)`, `ECGenParameterSpec("...")`): JCA `getInstance("...")`, `SSLContext`, `setEnabledProtocols`, Bouncy Castle imports, the libsignal Java/Android API without `new` (`ECPrivateKey.generate()`, `ECKeyPair.generate()`, `KEMKeyPair.generate(KEMKeyType.KYBER_1024)`, `KEMPublicKey(bytes)`, `HKDF.deriveSecrets`, `Aes256GcmSiv(key)`, `PreKeyBundle(...)`, `SealedSessionCipher(...)`) and the library's own native bridge inside `org.signal.libsignal` (`Native.ECPrivateKey_Generate()`, `Native.ECPrivateKey_Agree` / `_Sign` / `_HpkeOpen`, `Native.KyberKeyPair_Generate()`, `Native.HKDF_DeriveSecrets`, `Native.Aes256GcmSiv_New`); plus rules added for the JVM in 0.4.0 that apply to Java too: Android Keystore (`KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")` with the size, curve, block mode and padding read from the `KeyGenParameterSpec.Builder` passed to `initialize` / `init`, inline or through a variable), Tink (`KeyTemplates.get("AES256_GCM")`, `AeadKeyTemplates.X`, `PredefinedAeadParameters.X`; `*Config.register()` and the import count as import-level evidence), OkHttp (`ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)`, `.tlsVersions(TlsVersion.TLS_1_2)`, `.cipherSuites(CipherSuite.TLS_...)`, `CertificatePinner.Builder()`), and certificate-chain validation (`CertPathValidator.getInstance("PKIX")`, `TrustManagerFactory.getInstance`, `X509TrustManager`, `checkServerTrusted`, `KeyStore.getInstance("AndroidCAStore")`) | | Any text file | complete PEM blocks: certificates (parsed for subject, validity and key algorithm), public keys, private keys | | Manifests | `package.json`, `requirements*.txt`, `pyproject.toml`, `pom.xml`, `build.gradle(.kts)`, `libs.versions.toml`, `go.mod`, `Cargo.toml`, `Package.swift`, `Podfile`, `*.podspec`: cryptographic libraries that are declared (`pkg:swift/github.com/apple/swift-crypto@3.0.0`, `pkg:cocoapods/CryptoSwift@1.8`, `pkg:maven/org.signal/libsignal-android@0.105.1`) | In Go an unused import does not compile, so an import of a crypto package is evidence of use; the import-level finding is dropped when the same file has a call-level finding that covers it. A blank import (`_ "crypto/sha512"`) is reported with a note that it only registers an implementation. In Rust (added in 0.3.0, 2026-10-18, after the first run on a tree of 1,320 Rust files returned zero Rust findings) the `use` declarations decide what a name means: `SigningKey` is Ed25519 when it comes from `ed25519-dalek`, ML-DSA from `ml-dsa`, SLH-DSA from `slh-dsa`, ECDSA from `p256`/`k256`, RSA from `rsa`. A name like that with no `use` naming its crate is reported as `unknown` with the candidates listed, not guessed. An unused import is only a warning in Rust, so an imported crypto crate is reported as an `import` finding only when the file has no recognized call from it. Not recognized: methods on values (`.sign()`, `.encrypt()`) without their constructor in the same file, macros, and a project's own wrapper types (the file that calls the crate directly is the one reported). In Swift and Kotlin (added in 0.4.0, 2026-10-18, after the run on libsignal showed 321 files and 20.5% of the source lines not analyzed at all) the `import` lines decide what a name means, as the `use` lines do in Rust: `SHA256.hash` is CryptoKit only in a file that imports CryptoKit or Crypto, `IdentityKeyPair.generate()` is libsignal only with `import LibSignalClient` (or, inside the library, `import SignalFfi` or the `Sources/LibSignalClient/` target directory, because Swift does not declare the module in the file). A third-party library imported without a recognized call (LibSignalClient, CryptoSwift, Sodium) is reported once as import-level evidence; the system frameworks (CryptoKit, CommonCrypto, Security) are not, since Security is imported for the keychain in almost every app. Certificate-chain validation (`SecTrustEvaluate*`, JCA `CertPathValidator("PKIX")`, `X509TrustManager`, OkHttp pinning) is classified as a classical-signature exposure: the chain is signed by the CA with RSA or ECDSA, which a quantum computer could forge; it is an authentication exposure, not a key exchange, and nothing in that code alone can change it. Swift and Kotlin block comments nest; Swift string interpolation `\( )` and Kotlin templates `${ }` may contain strings and brackets, so the lexer scans them with an explicit stack (iteratively: a file made of `"\(` repeated would be tens of thousands of levels deep). The content of a string, interpolation included, is never a use. Not recognized: methods on values without their constructor in the same file (`generateSignature`, `signalEncrypt`), a project's own wrapper types, a module imported through an Objective-C bridging header, `#if DEBUG` as a scope, Kotlin extension functions over JCA. When the algorithm is given by a variable, the tool resolves it only when the file assigns that name exactly once, to a plain string literal, and the name is not a function parameter or loop variable (template literals whose `${...}` parts all resolve this way are also resolved). The finding then records where the value came from (`resolvedFrom`). In every other case the finding is `unknown`: the tool does not guess. At most 64 distinct variables are resolved per file; past that, variables in that file are reported as `unknown`, and the file is counted (`files-over-resolution-limit` in the CBOM, a line in the text summary). ## Cost on hostile input The scanner reads repositories it did not write, so its cost is kept linear in the size of each file, also for input built to be slow. Version 0.1.0, which was never published, had six places where a crafted file cost quadratic time, minutes for a single file of the default maximum size: PEM block search, JavaScript regex literals, Python triple-quoted strings on a long line, variable resolution repeated for every call, open-ended parameter-list patterns used by that resolution, and the assigned-variable lookup in Java. An adversarial review found them on 2026-09-29 and they were rewritten for 0.2.0; the rewritten forms give the same findings as the old ones: on 2026-09-29, `test/echivalenta-0.1.0.mjs` (which takes 0.1.0 from git history) found no difference on about 4,500 source files of the repository where the tool is developed, including three large npm packages, nor on 300,000 generated inputs to the lexer and the PEM search. `test/proba-timp.mjs` keeps the cost linear, and `test/control-negativ-timp.mjs` shows that it turns red when a quadratic form comes back. One cost remains bounded rather than linear: the arguments of a call are read for at most 4,000 characters, so a file made of thousands of unclosed calls is slower (about 10 seconds per megabyte on the machine where it was measured) but still finishes. ## Classification Every finding carries one class, a written reason and, when action is needed, a recommendation. | Class | Meaning | |---|---| | `quantum-vulnerable` | Broken by Shor's algorithm on a cryptographically relevant quantum computer: RSA, DSA, finite-field DH, ECDSA, ECDH, EdDSA and X25519/X448 on any classical curve, secp256k1, pairing-based schemes. Also TLS configurations that allow TLS 1.2, which has no standardized post-quantum key exchange. | | `weak-now` | Unsafe against classical attackers today: MD5, SHA-1, DES, 3DES, RC4, RC2, 64-bit-block ciphers, ECB mode, SSL and TLS below 1.2, RSA/DH below 2048 bits, curves below 112-bit security, JWT `none`. When the algorithm is also quantum-vulnerable (for example RSA-1024, or an RSA signature over SHA-1), the finding says so (`quantumVulnerable: true`). | | `quantum-safe` | ML-KEM (FIPS 203), ML-DSA (FIPS 204), SLH-DSA (FIPS 205), Falcon, hybrid key-exchange groups such as X25519MLKEM768, AES, ChaCha20-Poly1305, SHA-2, SHA-3, HMAC and KDFs over them. For AES-128 the reason notes that Grover's algorithm gives at most a quadratic speed-up (NIST category 1) and that AES-256 is preferred for long-lived data. | | `unknown` | The algorithm cannot be determined statically: it comes from a parameter, from configuration, from a key loaded at run time, or from a library that offers many algorithms without a recognized call. | Recommendations are concrete but do not pretend a migration is simple. Examples: key exchange in TLS moves to X25519MLKEM768; signatures move to ML-DSA-65 or a hybrid classical+ML-DSA signature (the X.509 composite signature format was still an IETF draft when the rules were written); secp256k1 in a wallet moves at the account level to an account with a post-quantum owner, which is a protocol change and not a library swap; for JWT the tool says plainly that no final post-quantum JWS standard exists yet. Statements about standards and runtime defaults reflect the time the rules were written; check the current status before acting on them. A finding states which algorithm appears where. It does not judge intent: a TLS scanner that deliberately offers classical groups to test a server will be reported like any other client. ## Output ### CBOM (CycloneDX 1.6) - `bomFormat: "CycloneDX"`, `specVersion: "1.6"`, `serialNumber` (`urn:uuid:...`), `version: 1`. - `metadata.timestamp`, `metadata.tools.components` (this tool and its version), `metadata.component` (the scanned directory), `metadata.properties` (scan statistics: files seen, analyzed, not read and why, excluded directories, limits). - One component of `type: "cryptographic-asset"` per distinct asset, with a stable `bom-ref` derived from its content: - `cryptoProperties.assetType`: `algorithm`, `protocol`, `certificate` or `related-crypto-material`. - `algorithmProperties`: `primitive`, `parameterSetIdentifier`, `curve`, `mode`, `padding`, `cryptoFunctions`, `classicalSecurityLevel`, `nistQuantumSecurityLevel` (0 for quantum-vulnerable and weak algorithms; omitted when not defined). - `protocolProperties`: `type: "tls"`, `version`. - `certificateProperties`: subject, issuer, validity, format. - `relatedCryptoMaterialProperties`: `type` (`private-key` or `public-key`), `format`, `size`. **The value of key material is never written.** - `oid` where it is standard and certain. - `evidence.occurrences[]`: `location` (path relative to the scanned directory), `line`, `symbol` (the API), `additionalContext` (the matched call, not the source line). - `properties` in the `aere:crypto-inventory:` namespace: `classification`, `quantum-vulnerable`, `reason`, `recommendation`, `languages`, `evidence-kinds`, `resolved-from`. - Declared libraries from manifests are components of `type: "library"` with `purl`, the manifest location, and `aere:crypto-inventory:declared-only = true`. **Declaration is not use**: a library can be declared and never called, and the cryptographic-asset components are the evidence of use. `--deterministic` derives the serial number from the content and omits the timestamp, so two runs on the same tree produce identical files. ### Text summary (`--summary`) Files seen, analyzed per language, checked for PEM only, and **not read, with the reason** (binary, too large, over the file limit, unreadable), plus excluded directories and symlinks not followed. Nothing is skipped silently: the scan fails with an accounting error if the categories do not add up to the number of files seen. Then counts per class and per language, the most frequent assets, the files with quantum-vulnerable findings, and the declared libraries. ## Command line ``` node inventar.mjs scan [options] --out write the CBOM to (default: stdout, unless --summary is given) --summary print the text summary --fail-on exit 1 when findings match: vulnerable, weak, unknown, any (comma-separated) --max-file-bytes skip and count files larger than n bytes (default 1048576) --max-files read at most n files; the rest are counted, not read (default 50000) --exclude-dir do not descend into directories with this name (repeatable) --no-default-excludes also descend into .git, node_modules, __pycache__, .venv, venv, ... --deterministic reproducible output (content-derived serial number, no timestamp) --findings-json write the raw findings, one object per occurrence ``` Exit codes: `0` success, `1` the `--fail-on` condition is met, `2` usage or scan error. `--fail-on vulnerable` matches every finding a quantum computer would break, including `weak-now` findings that are also quantum-vulnerable. ### In CI ```yaml - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 24 - name: Cryptographic inventory run: node tools/crypto-inventory/inventar.mjs scan . --out cbom.json --summary --fail-on vulnerable - uses: actions/upload-artifact@v4 if: always() with: name: cbom path: cbom.json ``` Start with `--fail-on weak` if the goal is to block known-broken algorithms first, and add `vulnerable` once a migration plan exists; failing a build on every ECDSA use from day one usually only teaches people to disable the check. ### As a module ```js import { scan, buildCbom, renderSummary, checkFailOn, classify } from './inventar.mjs'; const result = scan('./src', { maxFileBytes: 1 << 20 }); const cbom = buildCbom(result, { deterministic: true }); console.log(renderSummary(result)); ``` ## What it does NOT do - It does not see cryptography inside compiled binaries (`.class`, `.jar`, `.so`, `.dll`, executables) or inside transitive dependencies. It reads the source you point it at, and lists the libraries your manifests declare. - It does not find hand-written implementations (for example a local Keccak or AES routine), calls made through wrappers or reflection, dynamically computed imports, or algorithms chosen in configuration files at run time. Those appear as `unknown` at best, or not at all. - It does not prove that a use is exploitable, reachable or security-relevant. MD5 used as a cache key is reported like MD5 used for signatures; the reason text tells you what to check. - It is static pattern analysis. Supported languages: JavaScript/TypeScript, Python, Java, Go, Rust, Swift and Kotlin. Scala, C#, C, C++ and Objective-C files are only checked for PEM blocks. Files that are not UTF-8 text (including UTF-16) are treated as binary and counted as not read. - It does not execute anything from the scanned tree, and it makes no network requests. ## Tests ``` node test/proba.mjs # 68 probes: fixtures per language with the exact expected findings node test/control-negativ.mjs # breaks the scanner in a copy, one rule at a time (35 mutations), and # requires the matching probe to fail while the rest of the suite still runs node test/proba-timp.mjs # 13 hostile inputs, each at two sizes: the cost must grow linearly node test/control-negativ-timp.mjs # puts each quadratic form back (and one Swift comment rescan) and requires the cost probe to turn red node test/echivalenta-0.1.0.mjs # same findings as 0.1.0 (needs the git history; skipped without it); detectors # added after 0.1.0 (Rust, Swift, Kotlin, the libsignal Java/Node API, the JVM rules of 0.4.0) # are counted apart, and a finding that 0.1.0 had and the current version lacks is always a difference ``` Measured on 2026-10-18 on the libsignal tree (1,788 files; 596 Rust, 343 Java, 173 TypeScript, 210 Swift, 111 Kotlin): before the additions of that day the inventory returned 450 findings, all in Rust and Python, and zero for Java and TypeScript, because libsignal does not call JCA or `node:crypto` but its own API over the native bridge; Swift and Kotlin (20.5% of the source lines) were not analyzed at all. After the 0.3.0 additions: 759 findings (Java 262). The Node side of libsignal itself still returns zero, because its own tree imports by relative path, not by the package name the detector anchors on. Measured later on 2026-10-18, same tree, with the Swift and Kotlin detectors of 0.4.0: all 1,440 source files are analyzed (no source line left out by construction); 980 findings; Swift 0 to 134 in 65 files (84 calls, 50 import-level; 128 in test paths, 6 in production: X25519 49, Kyber-1024 8, AES-256-GCM 9, AES-256-CTR 4, HKDF 4, AES-256-GCM-SIV 1, session setup 9); Kotlin 0 to 67 in 52 files (22 calls, 2 configurations, 43 import-level; 20 in test paths, 47 in production: X.509 chain validation 7, Kyber-1024 6, X25519 5, XEdDSA 2, HKDF 2, MD5 1); Java 262 to 282 through the JVM rules shared with Kotlin. Eleven files were read by hand against the inventory (six Swift, five Kotlin): eight correct at the first run, three with a false negative each (the static form `Aes256Ctr32.process`, a library source without any import, the Curve25519 bridge calls for signing, agreement and HPKE), all three fixed the same hour with their form added to the fixtures. Of 38 Swift/Kotlin files whose code mentions a cryptographic API name, 8 have no finding, each for a stated reason (function definitions, handle wrappers, sender keys, randomness). Fixtures mark each expected finding on its own line (`EXPECT: | `), and a probe fails on a missing finding, an extra finding, a wrong line or a wrong class. Negative fixtures cover comments, docstrings, strings that mention crypto APIs, a file without cryptography, algorithms from unresolvable variables, a binary file and the size and count limits. The private-key probe generates a key at run time (none is stored in the repository) and checks that no part of it appears in any output. Developed and tested on Node.js 24; older versions are not tested.