diff --git a/README.md b/README.md index 4878286..b25ccda 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Aere Quantum -Self-hosted post-quantum infrastructure from Aere Network. Three components, each a few files with **no dependencies**: +Self-hosted post-quantum infrastructure from Aere Network. Four components, each a few files with **no dependencies**: Node.js 24 and the OpenSSL 3.5 it ships with (`node:crypto`), nothing from a package registry. | component | what it does | @@ -8,6 +8,7 @@ Node.js 24 and the OpenSSL 3.5 it ships with (`node:crypto`), nothing from a pac | [`pq-gateway/`](pq-gateway/) | a TLS 1.3 terminating proxy in front of any HTTP service, with the hybrid key exchange X25519MLKEM768: `hybrid-only` refuses a classical client at the handshake, `hybrid-preferred` keeps it working; optional client authentication with ML-DSA certificates | | [`pq-kms/`](pq-kms/) | a transit-style key management service where every key is hybrid: X25519 + ML-KEM-768 for encryption, Ed25519 + ML-DSA-65 for signatures (both halves required); versions, rotation, rewrap, data keys, a chained audit log; the root key from the environment or sealed by an HSM through PKCS#11 | | [`pq-pki/`](pq-pki/) | a private certificate authority for ML-DSA (X.509 v3, RFC 9881): root and issuing CAs, leaf certificates, revocation lists, and a strict chain verifier compared against OpenSSL | +| [`crypto-inventory/`](crypto-inventory/) | a cryptographic inventory of source code (JavaScript/TypeScript, Python, Java, Go, PEM blocks, dependency manifests): every use classified by its exposure to a quantum computer, with a migration target, written as a CycloneDX 1.6 CBOM; nothing from the scanned tree is executed, and its cost stays linear on input built to be slow | Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them. @@ -22,9 +23,11 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j | pq-gateway | 33/33 (`node proba-pq-gateway.mjs`) | 33/33 (`bash proba-pq-gateway-control-negativ.sh`) | | pq-kms | 62/62 (`node test/proba.mjs`); HSM root on SoftHSM2 + OpenSC 20/20 (`test/proba-hsm.mjs`, Linux); sealed-file trust rules 7/7 (`test/proba-hsm-incredere.mjs`) | 16/16 (`node test/control-negativ.mjs`); sealed-file rules 2/2 in this repository (`test/control-negativ-hsm-incredere.mjs`) | | pq-pki | 27/27 (`node test/proba.mjs`), each verdict compared with OpenSSL 3.5 | 22/22 (`node test/control-negativ.mjs`) | +| crypto-inventory | 37/37 (`node test/proba.mjs`); cost on hostile input 8/8 linear (`node test/proba-timp.mjs`) | 18/18 (`node test/control-negativ.mjs`); cost 3/3 in this repository (`node test/control-negativ-timp.mjs`; its fourth case compares with version 0.1.0 from the development history and is skipped here) | -Test names and control messages are in Romanian; identifiers, error codes, messages and documentation are in English. +Code comments, internal names, test names and control messages are in Romanian, and so are the two command words of the KMS +HSM tool (explained in its README); error codes, error messages, exported names and documentation are in English. ## Licence -MIT, see [LICENSE](LICENSE). Files: 24 (pq-gateway 6, pq-kms 10, pq-pki 6). +MIT, see [LICENSE](LICENSE). Files: 66 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42). diff --git a/crypto-inventory/README.md b/crypto-inventory/README.md new file mode 100644 index 0000000..a8790ee --- /dev/null +++ b/crypto-inventory/README.md @@ -0,0 +1,202 @@ +# 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 | +| 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`, ...) | +| 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`) | +| 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)`, `go.mod`: cryptographic libraries that are declared | + +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. + +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 and Go. + Kotlin, Scala, C#, Rust, C and 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 # 37 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 (18 mutations), and + # requires the matching probe to fail while the rest of the suite still runs +node test/proba-timp.mjs # 8 hostile inputs, each at two sizes: the cost must grow linearly +node test/control-negativ-timp.mjs # puts each quadratic form back 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) +``` + +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. diff --git a/crypto-inventory/inventar.mjs b/crypto-inventory/inventar.mjs new file mode 100644 index 0000000..d8d0614 --- /dev/null +++ b/crypto-inventory/inventar.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +// Inventarul criptografic al unui cod sursa (CBOM CycloneDX 1.6). Fara dependinte externe. +// Folosire: node inventar.mjs scan [--out cbom.json] [--summary] [--fail-on vulnerable] +// Codul de iesire: 0 = bine, 1 = conditia --fail-on indeplinita, 2 = eroare de folosire sau de scanare. +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { scaneaza, VERSIUNE, DOSARE_EXCLUSE_IMPLICIT } from './lib/scan.mjs'; +import { construiesteCbom } from './lib/cbom.mjs'; +import { rezumatText, verificaFailOn, numarPeClase } from './lib/rezumat.mjs'; +import { evalueaza, CLS } from './lib/catalog.mjs'; + +export { scaneaza as scan, construiesteCbom as buildCbom, rezumatText as renderSummary, verificaFailOn as checkFailOn, numarPeClase as countByClass, evalueaza as classify, CLS as CLASSES, VERSIUNE as VERSION, DOSARE_EXCLUSE_IMPLICIT as DEFAULT_EXCLUDED_DIRS }; + +const AJUTOR = `aere-crypto-inventory ${VERSIUNE} +Usage: + node inventar.mjs scan [options] + +Options: + --out write the CycloneDX 1.6 CBOM to (default: stdout, unless --summary is given) + --summary print a text summary to stdout + --fail-on exit with code 1 if 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 ${DOSARE_EXCLUSE_IMPLICIT.join(', ')} + --deterministic serial number derived from content, no timestamp (reproducible output) + --findings-json write the raw findings (one object per occurrence) as JSON + +Exit codes: 0 ok, 1 --fail-on condition met, 2 usage or scan error.`; + +export function main(argv) { + const a = argv.slice(); + const cmd = a.shift(); + if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { process.stdout.write(AJUTOR + '\n'); return cmd ? 0 : 2; } + if (cmd === '--version') { process.stdout.write(VERSIUNE + '\n'); return 0; } + if (cmd !== 'scan') { process.stderr.write(`unknown command: ${cmd}\n${AJUTOR}\n`); return 2; } + let dir = null; + const o = { excludeDirs: [] }; + let out = null; + let summary = false; + let failOn = null; + let determinist = false; + let findingsJson = null; + const numar = (x, nume) => { + if (!/^\d+$/.test(String(x))) throw new Error(`${nume} needs a non-negative integer`); + return Number(x); + }; + try { + while (a.length) { + const x = a.shift(); + if (x === '--out') out = a.shift(); + else if (x === '--summary') summary = true; + else if (x === '--fail-on') failOn = a.shift(); + else if (x === '--max-file-bytes') o.maxFileBytes = numar(a.shift(), x); + else if (x === '--max-files') o.maxFiles = numar(a.shift(), x); + else if (x === '--exclude-dir') o.excludeDirs.push(a.shift()); + else if (x === '--no-default-excludes') o.noDefaultExcludes = true; + else if (x === '--deterministic') determinist = true; + else if (x === '--findings-json') findingsJson = a.shift(); + else if (x.startsWith('--')) throw new Error(`unknown option: ${x}`); + else if (!dir) dir = x; + else throw new Error(`unexpected argument: ${x}`); + } + if (!dir) throw new Error('missing '); + if (out === undefined || findingsJson === undefined || failOn === undefined) throw new Error('option is missing its value'); + verificaFailOn([], failOn); + } catch (err) { + process.stderr.write(`error: ${err.message}\n${AJUTOR}\n`); + return 2; + } + let rez; + try { + rez = scaneaza(dir, o); + } catch (err) { + process.stderr.write(`error: ${err.message}\n`); + return 2; + } + const bom = construiesteCbom(rez, { deterministic: determinist }); + const json = JSON.stringify(bom, null, 2) + '\n'; + if (out) { + writeFileSync(out, json); + process.stderr.write(`CBOM written: ${resolve(out)} (${bom.components.length} components)\n`); + } + if (findingsJson) writeFileSync(findingsJson, JSON.stringify(rez.findings, null, 2) + '\n'); + if (summary) process.stdout.write(rezumatText(rez) + '\n'); + if (!out && !summary) process.stdout.write(json); + const f = verificaFailOn(rez.findings, failOn); + if (f.esec) { + process.stderr.write(`fail-on ${failOn}: ${f.motive.join('; ')}\n`); + return 1; + } + return 0; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv.slice(2)); +} diff --git a/crypto-inventory/lib/catalog.mjs b/crypto-inventory/lib/catalog.mjs new file mode 100644 index 0000000..314192a --- /dev/null +++ b/crypto-inventory/lib/catalog.mjs @@ -0,0 +1,418 @@ +// Catalogul de clasificare. Fiecare gasire primeste o clasa, un motiv scris si, cand e +// vulnerabila, o recomandare de migrare. Textele pentru utilizator sunt in engleza. +// +// Reguli: o clasa nu se ghiceste. "unknown" inseamna ca valoarea nu se poate afla static. + +export const CLS = Object.freeze({ + V: 'quantum-vulnerable', + W: 'weak-now', + S: 'quantum-safe', + U: 'unknown', +}); + +export const REC = Object.freeze({ + SIG: 'Migrate signatures to ML-DSA-65 (FIPS 204), or to a hybrid (composite) classical+ML-DSA signature during the transition so that both would have to be broken. For long-lived roots of trust consider SLH-DSA (FIPS 205). Keys and signatures grow (ML-DSA-65: 1,952-byte public key, 3,309-byte signature): check protocol, storage and column limits. Composite signatures for X.509 were still an IETF draft when this rule was written.', + KEX: 'For TLS, offer the hybrid group X25519MLKEM768 in TLS 1.3 (built into Go 1.24+ and OpenSSL 3.5+). For application-level key establishment use ML-KEM-768 (FIPS 203), ideally combined with X25519 during the transition. Traffic protected by a classical key exchange can be recorded now and decrypted later, so start with data that must stay confidential for years.', + PKE: 'Replace RSA encryption and key transport with ML-KEM-768 (FIPS 203) used as a KEM in front of an AEAD such as AES-256-GCM, ideally hybrid with X25519 during the transition. Ciphertexts recorded today become readable once a cryptographically relevant quantum computer exists; re-encrypt data that must stay confidential for years.', + SECP256K1: 'secp256k1 ECDSA is fixed by the account and transaction format of Ethereum-style and Bitcoin-style chains, so this is not a library swap. Plan the migration at the account level: move control of funds and roles to an account whose owner is a post-quantum key (for example an ML-DSA or Falcon key checked by a smart-contract account or by the chain itself), and stop reusing addresses whose public key is already exposed on chain.', + JWT: 'There is no final standard for post-quantum JWS/JWT yet: ML-DSA for JOSE and COSE was an IETF draft when this rule was written, and mainstream JWT libraries do not ship it by default. What you can do now: keep token lifetimes short, use HS256/HS512 where issuer and verifier can share a secret, inventory where verification keys live so they can be rotated, and plan a hybrid (classical + ML-DSA) signature once the specification is final and your library supports it.', + JWT_NONE: 'An unsigned JWT ("none") must never be accepted: remove it from the accepted algorithms.', + HASH_WEAK: 'Replace with SHA-256, SHA-384 or SHA3-256. If this is a non-security checksum (cache key, deduplication), document that; for passwords use a password hash (Argon2id, scrypt, bcrypt, or PBKDF2-HMAC-SHA-256 with a high iteration count).', + CIPHER_WEAK: 'Replace with AES-256-GCM or ChaCha20-Poly1305, with a unique nonce per key.', + ECB: 'ECB leaks plaintext patterns. Use an authenticated mode: AES-256-GCM (unique nonce per key) or ChaCha20-Poly1305.', + TLS_OLD: 'Disable SSL, TLS 1.0 and TLS 1.1; require at least TLS 1.2 and prefer TLS 1.3 with the hybrid group X25519MLKEM768.', + TLS12: 'TLS 1.2 has no standardized post-quantum key exchange. Prefer TLS 1.3 and offer X25519MLKEM768; keep TLS 1.2 only for clients that require it, and measure how many still negotiate it.', + TLS13: 'Check the negotiated key-exchange groups: offer X25519MLKEM768 first. Go 1.24+ does this by default when CurvePreferences is unset, and OpenSSL 3.5+ includes it in its default groups; older runtimes negotiate a classical group.', + AES128: 'Grover\'s algorithm gives at most a quadratic speed-up, which NIST treats as security category 1 for AES-128. Frameworks such as CNSA 2.0 require AES-256; prefer AES-256 for data that must stay confidential for decades.', + AES_SIZE: 'Key size is set at run time by the key length. AES-128 is NIST category 1 against a quantum attacker, AES-256 category 5; prefer AES-256 for long-lived data.', + PREPQ: 'Pre-standard variant: migrate to the final NIST standard (ML-KEM FIPS 203, ML-DSA FIPS 204, SLH-DSA FIPS 205, or FN-DSA for Falcon once published). Keys and outputs are not interoperable with the final versions.', + BROKEN_PQ: 'Broken by classical attacks in 2022. Remove it; use ML-KEM (FIPS 203) or ML-DSA (FIPS 204).', + UNKNOWN: 'Find where the value comes from (configuration, environment, caller) and review it there; this tool does not guess.', + LIB_MULTI: 'The library offers both classical and post-quantum-safe primitives; no specific call was recognized in this file. Review how it is used.', +}); + +// numele canonice ale curbelor +const ALIAS_CURBE = [ + [/^(p-?256|prime256v1|secp256r1|nist256p|curvep256|p256)$/i, 'secp256r1'], + [/^(p-?384|secp384r1|nist384p|curvep384|p384)$/i, 'secp384r1'], + [/^(p-?521|secp521r1|nist521p|curvep521|p521)$/i, 'secp521r1'], + [/^(p-?224|secp224r1|nist224p|p224)$/i, 'secp224r1'], + [/^(p-?192|prime192v1|secp192r1|nist192p|p192)$/i, 'secp192r1'], + [/^(secp256k1|k256|k-256)$/i, 'secp256k1'], + [/^brainpoolp256r1$/i, 'brainpoolP256r1'], + [/^brainpoolp384r1$/i, 'brainpoolP384r1'], + [/^brainpoolp512r1$/i, 'brainpoolP512r1'], + [/^(curve25519|x25519)$/i, 'Curve25519'], + [/^(ed25519|edwards25519)$/i, 'Edwards25519'], + [/^(curve448|x448)$/i, 'Curve448'], + [/^(ed448|edwards448)$/i, 'Edwards448'], +]; +const NIVEL_CURBA = { + secp256r1: 128, secp384r1: 192, secp521r1: 256, secp224r1: 112, secp192r1: 96, secp256k1: 128, + brainpoolP256r1: 128, brainpoolP384r1: 192, brainpoolP512r1: 256, +}; + +export function curbaCanonica(s) { + if (!s) return null; + const t = String(s).trim(); + for (const [re, nume] of ALIAS_CURBE) if (re.test(t)) return nume; + return t; +} + +// hash-uri: nume canonic, clasa, nivel NIST (categoria de coliziune), OID +const HASH = { + MD2: { n: 'MD2', c: CLS.W }, MD4: { n: 'MD4', c: CLS.W }, + MD5: { n: 'MD5', c: CLS.W, oid: '1.2.840.113549.2.5' }, + SHA1: { n: 'SHA-1', c: CLS.W, oid: '1.3.14.3.2.26' }, + RIPEMD160: { n: 'RIPEMD-160', c: CLS.W }, + SHA224: { n: 'SHA-224', c: CLS.S }, + SHA256: { n: 'SHA-256', c: CLS.S, q: 2, cl: 128, oid: '2.16.840.1.101.3.4.2.1' }, + SHA384: { n: 'SHA-384', c: CLS.S, q: 4, cl: 192, oid: '2.16.840.1.101.3.4.2.2' }, + SHA512: { n: 'SHA-512', c: CLS.S, q: 4, cl: 256, oid: '2.16.840.1.101.3.4.2.3' }, + 'SHA512/256': { n: 'SHA-512/256', c: CLS.S, q: 2 }, + 'SHA512/224': { n: 'SHA-512/224', c: CLS.S }, + 'SHA3-224': { n: 'SHA3-224', c: CLS.S }, + 'SHA3-256': { n: 'SHA3-256', c: CLS.S, q: 2 }, + 'SHA3-384': { n: 'SHA3-384', c: CLS.S, q: 4 }, + 'SHA3-512': { n: 'SHA3-512', c: CLS.S, q: 4 }, + SHAKE128: { n: 'SHAKE128', c: CLS.S, xof: true }, + SHAKE256: { n: 'SHAKE256', c: CLS.S, xof: true }, + BLAKE2B: { n: 'BLAKE2b', c: CLS.S }, BLAKE2S: { n: 'BLAKE2s', c: CLS.S }, + BLAKE2B512: { n: 'BLAKE2b-512', c: CLS.S }, BLAKE2S256: { n: 'BLAKE2s-256', c: CLS.S }, + SM3: { n: 'SM3', c: CLS.S }, + KECCAK256: { n: 'Keccak-256', c: CLS.S }, +}; + +export function hashCanonic(s) { + if (!s) return null; + let t = String(s).trim().toUpperCase().replace(/^RSA-/, ''); + t = t.replace(/_/g, '-'); + if (/^SHA-?1$|^SHA$/.test(t)) return 'SHA1'; + if (/^SHA-?(224|256|384|512)$/.test(t)) return 'SHA' + t.replace(/\D/g, ''); + if (/^SHA-?512[/-](224|256)$/.test(t)) return 'SHA512/' + t.slice(-3); + if (/^SHA3-?(224|256|384|512)$/.test(t)) return 'SHA3-' + t.slice(-3); + if (/^SHAKE-?(128|256)$/.test(t)) return 'SHAKE' + t.slice(-3); + if (/^MD[245]$/.test(t)) return t; + if (/^RIPEMD-?160$|^RMD160$/.test(t)) return 'RIPEMD160'; + if (/^BLAKE2B-?512$/.test(t)) return 'BLAKE2B512'; + if (/^BLAKE2S-?256$/.test(t)) return 'BLAKE2S256'; + if (/^BLAKE2[BS]$/.test(t)) return t; + if (/^SM3$/.test(t)) return 'SM3'; + if (/^KECCAK-?256$/.test(t)) return 'KECCAK256'; + return null; +} + +function rez(o) { + return { + classification: o.c, + quantumVulnerable: !!o.qv, + reason: o.motiv, + recommendation: o.rec || null, + nistQuantumSecurityLevel: o.q, + classicalSecurityLevel: o.cl, + oid: o.oid, + }; +} + +const SHOR = 'Shor\'s algorithm on a cryptographically relevant quantum computer recovers the private key from the public key'; + +// intrarea: { grup, nume?, param?, curba?, hash?, mod?, primitiv?, context? } +// iesirea: descrierea completa (nume canonic, primitiv, clasa, motiv, recomandare, niveluri) +export function evalueaza(a) { + const g = a.grup; + const f = (x) => Object.assign({ grup: g, nume: a.nume, primitiv: a.primitiv || 'unknown', param: a.param, curba: a.curba, mod: a.mod, padding: a.padding }, x); + + if (g === 'UNKNOWN') { + return f(rez({ c: CLS.U, motiv: a.motiv || 'Algorithm could not be determined statically.', rec: REC.UNKNOWN })); + } + + if (g === 'RSA') { + const bits = a.param ? Number(a.param) : null; + const nume = a.nume || (bits ? `RSA-${bits}` : 'RSA'); + const prim = a.primitiv || 'unknown'; + const rec = prim === 'pke' ? REC.PKE : (a.context === 'jwt' ? REC.JWT : (prim === 'signature' ? REC.SIG : `${REC.SIG} If the key is used for encryption: ${REC.PKE}`)); + const hashSlab = a.hash && HASH[a.hash] && HASH[a.hash].c === CLS.W; + if (bits && bits < 2048) { + return f({ nume, ...rez({ c: CLS.W, qv: true, motiv: `RSA with a ${bits}-bit modulus is below the 2048-bit minimum (NIST SP 800-131A); also quantum-vulnerable: ${SHOR}.`, rec, q: 0, oid: '1.2.840.113549.1.1.1' }) }); + } + if (hashSlab) { + return f({ nume, ...rez({ c: CLS.W, qv: true, motiv: `RSA signature over ${HASH[a.hash].n}, which has practical collision attacks; also quantum-vulnerable: ${SHOR}.`, rec: `${REC.HASH_WEAK} ${rec}`, q: 0 }) }); + } + const cl = bits === 2048 ? 112 : bits === 3072 ? 128 : bits === 7680 ? 192 : bits === 15360 ? 256 : undefined; + return f({ nume, ...rez({ c: CLS.V, qv: true, motiv: `RSA: ${SHOR}.`, rec, q: 0, cl, oid: '1.2.840.113549.1.1.1' }) }); + } + + if (g === 'DSA') { + const bits = a.param ? Number(a.param) : null; + const nume = a.nume || (bits ? `DSA-${bits}` : 'DSA'); + if ((bits && bits < 2048) || (a.hash && HASH[a.hash] && HASH[a.hash].c === CLS.W)) { + return f({ nume, ...rez({ c: CLS.W, qv: true, motiv: `DSA with ${bits ? bits + '-bit parameters' : 'a weak hash'} is below current minimums; FIPS 186-5 no longer approves DSA for generating signatures; also ${SHOR}.`, rec: REC.SIG, q: 0 }) }); + } + return f({ nume, ...rez({ c: CLS.V, qv: true, motiv: `DSA: ${SHOR} (discrete logarithm). FIPS 186-5 no longer approves DSA for generating signatures.`, rec: REC.SIG, q: 0 }) }); + } + + if (g === 'DH') { + const bits = a.param && /^\d+$/.test(a.param) ? Number(a.param) : null; + const nume = a.nume || (a.param ? `DH-${a.param}` : 'DH'); + if (bits && bits < 2048) { + return f({ nume, ...rez({ c: CLS.W, qv: true, motiv: `Finite-field Diffie-Hellman with a ${bits}-bit group is below the 2048-bit minimum; also ${SHOR} (discrete logarithm).`, rec: REC.KEX, q: 0 }) }); + } + return f({ nume, ...rez({ c: CLS.V, qv: true, motiv: `Finite-field Diffie-Hellman: ${SHOR} (discrete logarithm), so recorded key exchanges can be decrypted later.`, rec: REC.KEX, q: 0 }) }); + } + + if (g === 'EC' || g === 'ECDSA' || g === 'ECDH' || g === 'SECP256K1') { + const curba = curbaCanonica(a.curba) || (g === 'SECP256K1' ? 'secp256k1' : null); + const e256k1 = curba === 'secp256k1'; + const eticheta = g === 'SECP256K1' ? 'ECDSA' : g; + const nume = a.nume || (curba ? `${eticheta}-${curba}` : eticheta); + const cl = curba ? NIVEL_CURBA[curba] : undefined; + const prim = a.primitiv || (g === 'ECDH' ? 'key-agree' : (g === 'EC' ? 'other' : 'signature')); + let rec = g === 'ECDH' ? REC.KEX : (g === 'EC' ? `Signatures: ${REC.SIG} Key agreement: ${REC.KEX}` : REC.SIG); + if (e256k1 && g !== 'ECDH') rec = REC.SECP256K1; + if (a.context === 'jwt') rec = REC.JWT; + const baza = { nume, primitiv: prim, curba: curba || undefined, grup: e256k1 ? 'SECP256K1' : g }; + if (cl !== undefined && cl < 112) { + return f({ ...baza, ...rez({ c: CLS.W, qv: true, motiv: `Elliptic curve ${curba} gives about ${cl}-bit classical security, below the 112-bit minimum; also ${SHOR} (elliptic-curve discrete logarithm).`, rec, q: 0, cl }) }); + } + if (a.hash && HASH[a.hash] && HASH[a.hash].c === CLS.W) { + return f({ ...baza, ...rez({ c: CLS.W, qv: true, motiv: `ECDSA over ${HASH[a.hash].n}, which has practical collision attacks; also ${SHOR}.`, rec: `${REC.HASH_WEAK} ${rec}`, q: 0 }) }); + } + const pe = curba ? ` on ${curba}` : ''; + const motiv = e256k1 + ? `ECDSA on secp256k1: ${SHOR} (elliptic-curve discrete logarithm). On public blockchains the public key is visible on chain after the first signed transaction.` + : `${eticheta}${pe}: ${SHOR} (elliptic-curve discrete logarithm).`; + return f({ ...baza, ...rez({ c: CLS.V, qv: true, motiv, rec, q: 0, cl, oid: '1.2.840.10045.2.1' }) }); + } + + if (g === 'EDDSA') { + const nume = a.nume || 'Ed25519'; + const e448 = /448/.test(nume); + const rec = a.context === 'jwt' ? REC.JWT : REC.SIG; + return f({ nume, primitiv: 'signature', curba: e448 ? 'Edwards448' : 'Edwards25519', ...rez({ c: CLS.V, qv: true, motiv: `${nume}: ${SHOR} (elliptic-curve discrete logarithm).`, rec, q: 0, cl: e448 ? 224 : 128, oid: e448 ? '1.3.101.113' : '1.3.101.112' }) }); + } + + if (g === 'XDH') { + const nume = a.nume || 'X25519'; + const e448 = /448/.test(nume); + return f({ nume, primitiv: 'key-agree', curba: e448 ? 'Curve448' : 'Curve25519', ...rez({ c: CLS.V, qv: true, motiv: `${nume} key agreement: ${SHOR} (elliptic-curve discrete logarithm), so recorded key exchanges can be decrypted later.`, rec: REC.KEX, q: 0, cl: e448 ? 224 : 128, oid: e448 ? '1.3.101.111' : '1.3.101.110' }) }); + } + + if (g === 'PAIRING') { + return f({ nume: a.nume || 'BLS12-381', primitiv: a.primitiv || 'signature', ...rez({ c: CLS.V, qv: true, motiv: `${a.nume || 'Pairing-based cryptography'}: ${SHOR} (discrete logarithm in the pairing groups).`, rec: REC.SIG, q: 0 }) }); + } + + if (g === 'CLASSIC-SIG') { + // semnatura clasica al carei tip de cheie nu se vede (RSA, DSA sau ECDSA) + const h = a.hash && HASH[a.hash]; + const nume = a.nume || `signature-with-${h ? h.n : 'unknown-hash'}`; + if (h && h.c === CLS.W) { + return f({ nume, primitiv: 'signature', ...rez({ c: CLS.W, qv: true, motiv: `${a.motiv || 'Classical signature'} over ${h.n}, which has practical collision attacks; the key type (RSA, DSA or ECDSA) is also quantum-vulnerable.`, rec: `${REC.HASH_WEAK} ${REC.SIG}`, q: 0 }) }); + } + return f({ nume, primitiv: 'signature', ...rez({ c: CLS.V, qv: true, motiv: `${a.motiv || 'Classical signature (RSA, DSA or ECDSA key)'}: ${SHOR}.`, rec: REC.SIG, q: 0 }) }); + } + + if (g === 'HASH') { + const h = HASH[a.hash]; + if (!h) return f({ nume: a.nume || String(a.hash), primitiv: 'hash', ...rez({ c: CLS.U, motiv: `Hash "${a.hash}" is not in this tool's catalog.`, rec: REC.UNKNOWN }) }); + const nota = a.nota ? ` ${a.nota}` : ''; + if (h.c === CLS.W) { + const motiv = a.hash === 'RIPEMD160' + ? `RIPEMD-160 has a 160-bit output (about 80-bit collision resistance), below the 112-bit minimum for new designs.${nota}` + : `${h.n} has practical collision attacks; unsafe for signatures, certificates and integrity against an adversary.${nota}`; + return f({ nume: h.n, primitiv: 'hash', ...rez({ c: CLS.W, motiv, rec: REC.HASH_WEAK, q: 0, oid: h.oid }) }); + } + return f({ nume: h.n, primitiv: h.xof ? 'xof' : 'hash', ...rez({ c: CLS.S, motiv: `${h.n}: quantum attacks give at most a polynomial speed-up for collisions and preimages (Grover, BHT); output size keeps it within NIST categories.${nota}`, q: h.q, cl: h.cl, oid: h.oid }) }); + } + + if (g === 'MAC') { + if (!a.hash) return f({ nume: 'HMAC', primitiv: 'mac', ...rez({ c: CLS.U, motiv: 'HMAC whose hash function is bound to the key object (set where the key is imported or generated), not visible at this call.', rec: REC.UNKNOWN }) }); + const h = HASH[a.hash]; + const nume = `HMAC-${h ? h.n : String(a.hash || 'unknown')}`; + if (!h) return f({ nume, primitiv: 'mac', ...rez({ c: CLS.U, motiv: `HMAC hash "${a.hash}" is not in this tool's catalog.`, rec: REC.UNKNOWN }) }); + if (h.c === CLS.W) { + return f({ nume, primitiv: 'mac', ...rez({ c: CLS.W, motiv: `${nume}: HMAC does not depend on collision resistance and has no practical break, but ${h.n} is deprecated for new designs and should be retired.`, rec: 'Move to HMAC-SHA-256 or HMAC-SHA-384.', q: 0 }) }); + } + return f({ nume, primitiv: 'mac', ...rez({ c: CLS.S, motiv: `${nume}: symmetric; a quantum attacker gains at most a quadratic speed-up (Grover) on key search.` }) }); + } + + if (g === 'KDF') { + const h = a.hash ? HASH[a.hash] : null; + const nume = a.nume || 'KDF'; + if (h && h.c === CLS.W) { + return f({ nume, primitiv: 'kdf', ...rez({ c: CLS.W, motiv: `${nume} uses ${h.n}; not practically broken as a KDF, but deprecated for new designs.`, rec: 'Use PBKDF2-HMAC-SHA-256 (high iteration count), scrypt or Argon2id.', q: 0 }) }); + } + return f({ nume, primitiv: 'kdf', ...rez({ c: CLS.S, motiv: `${nume}: symmetric key derivation; a quantum attacker gains at most a quadratic speed-up (Grover).` }) }); + } + + if (g === 'CIPHER') return cifru(a, f); + + if (g === 'MLKEM') { + const p = String(a.param || ''); + const q = p === '512' ? 1 : p === '768' ? 3 : p === '1024' ? 5 : undefined; + const oid = p === '512' ? '2.16.840.1.101.3.4.4.1' : p === '768' ? '2.16.840.1.101.3.4.4.2' : p === '1024' ? '2.16.840.1.101.3.4.4.3' : undefined; + const nume = p ? `ML-KEM-${p}` : 'ML-KEM'; + return f({ nume, primitiv: 'kem', ...rez({ c: CLS.S, motiv: `${nume}: module-lattice KEM standardized in FIPS 203${q ? `, NIST category ${q}` : ' (parameter set not visible)'}.`, q, oid }) }); + } + if (g === 'MLDSA') { + const p = String(a.param || ''); + const q = p === '44' ? 2 : p === '65' ? 3 : p === '87' ? 5 : undefined; + const oid = p === '44' ? '2.16.840.1.101.3.4.3.17' : p === '65' ? '2.16.840.1.101.3.4.3.18' : p === '87' ? '2.16.840.1.101.3.4.3.19' : undefined; + const nume = p ? `ML-DSA-${p}` : 'ML-DSA'; + return f({ nume, primitiv: 'signature', ...rez({ c: CLS.S, motiv: `${nume}: module-lattice signature standardized in FIPS 204${q ? `, NIST category ${q}` : ' (parameter set not visible)'}.`, q, oid }) }); + } + if (g === 'SLHDSA') { + const p = String(a.param || ''); + const m = /(128|192|256)/.exec(p); + const q = m ? ({ 128: 1, 192: 3, 256: 5 })[m[1]] : undefined; + const nume = p ? `SLH-DSA-${p}` : 'SLH-DSA'; + return f({ nume, primitiv: 'signature', ...rez({ c: CLS.S, motiv: `${nume}: stateless hash-based signature standardized in FIPS 205${q ? `, NIST category ${q}` : ''}.`, q }) }); + } + if (g === 'FALCON') { + const p = String(a.param || ''); + const q = p === '512' ? 1 : p === '1024' ? 5 : undefined; + const nume = p ? `Falcon-${p}` : 'Falcon'; + return f({ nume, primitiv: 'signature', ...rez({ c: CLS.S, motiv: `${nume}: NTRU-lattice signature selected by NIST (to be standardized as FN-DSA)${q ? `, NIST category ${q}` : ''}.`, q }) }); + } + if (g === 'HASHSIG') { + return f({ nume: a.nume || 'XMSS/LMS', primitiv: 'signature', ...rez({ c: CLS.S, motiv: `${a.nume || 'Stateful hash-based signature'}: approved in NIST SP 800-208; security depends on never reusing a one-time key state.` }) }); + } + if (g === 'PREPQ') { + return f({ nume: a.nume, primitiv: a.primitiv, ...rez({ c: CLS.S, motiv: `${a.nume}: pre-standard version of a NIST-selected post-quantum scheme.`, rec: REC.PREPQ }) }); + } + if (g === 'BROKENPQ') { + return f({ nume: a.nume, primitiv: a.primitiv, ...rez({ c: CLS.W, motiv: `${a.nume} was broken by classical attacks in 2022.`, rec: REC.BROKEN_PQ, q: 0 }) }); + } + if (g === 'HQC') { + return f({ nume: a.nume || 'HQC', primitiv: 'kem', ...rez({ c: CLS.S, motiv: 'HQC: code-based KEM selected by NIST in 2025 as a second KEM; final standard pending.' }) }); + } + if (g === 'HYBRID-KEX') { + const pq = /1024/.test(a.nume) ? 5 : 3; + return f({ nume: a.nume, primitiv: 'kem', ...rez({ c: CLS.S, motiv: `${a.nume}: hybrid key exchange (classical ECDH + ML-KEM); stays secure if either component holds. The NIST category given is that of the ML-KEM component.`, q: pq }) }); + } + if (g === 'TLS') return tls(a, f); + if (g === 'JWT-HMAC') { + const h = HASH[a.hash]; + return f({ nume: a.nume, primitiv: 'mac', ...rez({ c: CLS.S, motiv: `${a.nume} is HMAC-${h ? h.n : a.hash}: symmetric, so a quantum attacker gains at most a quadratic speed-up (Grover); the shared secret must be long and random.` }) }); + } + if (g === 'JWT-NONE') { + return f({ nume: 'JWS none', primitiv: 'signature', ...rez({ c: CLS.W, motiv: 'The "none" algorithm means the token is not signed at all.', rec: REC.JWT_NONE, q: 0 }) }); + } + if (g === 'LIB-MULTI') { + return f({ nume: a.nume, primitiv: 'unknown', ...rez({ c: CLS.U, motiv: a.motiv || `Imports ${a.nume}; no specific algorithm call recognized in this file.`, rec: REC.LIB_MULTI }) }); + } + if (g === 'SSH') { + return f({ nume: a.nume || 'SSH', primitiv: 'unknown', ...rez({ c: CLS.U, motiv: a.motiv || 'SSH: key exchange and host-key algorithms are negotiated at run time and not visible here.', rec: 'Check the configured key-exchange algorithms: OpenSSH 9.9+ offers mlkem768x25519-sha256 and 9.0+ sntrup761x25519-sha512; host keys remain classical (Ed25519, ECDSA, RSA).' }) }); + } + return f(rez({ c: CLS.U, motiv: `No classification rule for group "${g}".`, rec: REC.UNKNOWN })); +} + +function cifru(a, f) { + const alg = String(a.nume || '').toUpperCase(); + const mod = a.mod ? String(a.mod).toLowerCase() : undefined; + if (/^(DES|DES-?CBC|DES-?ECB|DES-?CFB|DES-?OFB)$/.test(alg) || alg === 'DES') { + return f({ nume: mod ? `DES-${mod.toUpperCase()}` : 'DES', primitiv: 'block-cipher', mod, ...rez({ c: CLS.W, motiv: 'DES has a 56-bit key and is brute-forceable today.', rec: REC.CIPHER_WEAK, q: 0 }) }); + } + if (/^(3DES|DESEDE|DES-?EDE3?|TRIPLEDES|TDEA|DES3)$/.test(alg)) { + return f({ nume: mod ? `3DES-${mod.toUpperCase()}` : '3DES', primitiv: 'block-cipher', mod, ...rez({ c: CLS.W, motiv: '3DES (TDEA) has a 64-bit block (Sweet32 birthday attacks) and was disallowed by NIST after 2023.', rec: REC.CIPHER_WEAK, q: 0 }) }); + } + if (/^(RC4|ARC4|ARCFOUR)$/.test(alg)) { + return f({ nume: 'RC4', primitiv: 'stream-cipher', ...rez({ c: CLS.W, motiv: 'RC4 has exploitable keystream biases and is prohibited in TLS (RFC 7465).', rec: REC.CIPHER_WEAK, q: 0 }) }); + } + if (/^(RC2|ARC2)$/.test(alg)) { + return f({ nume: 'RC2', primitiv: 'block-cipher', ...rez({ c: CLS.W, motiv: 'RC2 is an obsolete cipher with a 64-bit block.', rec: REC.CIPHER_WEAK, q: 0 }) }); + } + if (/^(BLOWFISH|BF)$/.test(alg)) { + return f({ nume: 'Blowfish', primitiv: 'block-cipher', mod, ...rez({ c: CLS.W, motiv: 'Blowfish has a 64-bit block (Sweet32 birthday attacks).', rec: REC.CIPHER_WEAK, q: 0 }) }); + } + if (/^(CAST5|CAST|IDEA|SEED)$/.test(alg)) { + return f({ nume: alg, primitiv: 'block-cipher', mod, ...rez({ c: CLS.W, motiv: `${alg} has a 64-bit block (Sweet32 birthday attacks) or is obsolete.`, rec: REC.CIPHER_WEAK, q: 0 }) }); + } + if (/^CHACHA20(-POLY1305)?$|^XCHACHA20-POLY1305$|^XSALSA20-POLY1305$|^SALSA20$/.test(alg)) { + const ae = /POLY1305/.test(alg); + return f({ nume: a.nume, primitiv: ae ? 'ae' : 'stream-cipher', ...rez({ c: CLS.S, motiv: `${a.nume}: 256-bit key; a quantum attacker gains at most a quadratic speed-up (Grover) on key search.` }) }); + } + if (/^AES/.test(alg) || /^CAMELLIA/.test(alg) || /^SM4/.test(alg) || /^ARIA/.test(alg)) { + const familie = /^CAMELLIA/.test(alg) ? 'Camellia' : /^SM4/.test(alg) ? 'SM4' : /^ARIA/.test(alg) ? 'ARIA' : 'AES'; + const bits = a.param ? Number(a.param) : (familie === 'SM4' ? 128 : null); + const numeMod = mod ? `-${mod.toUpperCase()}` : ''; + const nume = `${familie}${bits ? '-' + bits : ''}${numeMod}`; + const ae = mod && /^(gcm|ccm|ocb|siv|gcm-siv|eax|poly1305)$/.test(mod); + const prim = ae ? 'ae' : 'block-cipher'; + const modCdx = mod && ['cbc', 'ecb', 'ccm', 'gcm', 'cfb', 'ofb', 'ctr'].includes(mod) ? mod : (mod ? 'other' : undefined); + if (mod === 'ecb') { + return f({ nume, primitiv: prim, mod: modCdx, ...rez({ c: CLS.W, motiv: `${nume}: ECB mode encrypts equal blocks to equal ciphertext and leaks plaintext structure.${a.nota ? ' ' + a.nota : ''}`, rec: REC.ECB, q: 0 }) }); + } + const q = bits === 128 ? 1 : bits === 192 ? 3 : bits === 256 ? 5 : undefined; + const cl = bits || undefined; + const rec = bits === 128 ? REC.AES128 : (bits ? null : REC.AES_SIZE); + const motiv = bits === 128 + ? `${nume}: symmetric; Grover's algorithm reduces key search at most quadratically, NIST category 1.` + : bits ? `${nume}: symmetric ${bits}-bit key; Grover's algorithm reduces key search at most quadratically, NIST category ${q}.` + : `${nume}: symmetric; key size not visible statically (AES-128 is NIST category 1, AES-256 category 5).`; + return f({ nume, primitiv: prim, mod: modCdx, param: bits ? String(bits) : undefined, ...rez({ c: CLS.S, motiv, rec, q, cl }) }); + } + return f({ nume: a.nume, primitiv: 'unknown', ...rez({ c: CLS.U, motiv: `Cipher "${a.nume}" is not in this tool's catalog.`, rec: REC.UNKNOWN }) }); +} + +// a.param = versiunea ('1.0','1.1','1.2','1.3','ssl3','negotiated'); a.rol = 'min'|'max'|'only' +function tls(a, f) { + const v = String(a.param || ''); + const rol = a.rol || 'only'; + const numeV = v === 'ssl3' ? 'SSLv3' : v === 'ssl2' ? 'SSLv2' : (v === 'negotiated' ? 'TLS (negotiated)' : `TLSv${v}`); + const nume = a.nume || numeV; + const baza = { nume, primitiv: 'other', protocolType: 'tls', protocolVersion: /^1\.[0-3]$/.test(v) ? v : (v === 'ssl3' ? '3.0' : undefined), rol }; + if (v === 'ssl2' || v === 'ssl3' || v === '1.0' || v === '1.1') { + if (rol === 'max') { + return f({ ...baza, ...rez({ c: CLS.W, qv: true, motiv: `${numeV} as the maximum version caps the connection at a deprecated protocol (RFC 8996).`, rec: REC.TLS_OLD, q: 0 }) }); + } + return f({ ...baza, ...rez({ c: CLS.W, qv: true, motiv: `${numeV} is deprecated (RFC 8996) and ${rol === 'min' ? 'allowed as the minimum version' : 'selected'} here.`, rec: REC.TLS_OLD, q: 0 }) }); + } + if (v === '1.2') { + if (rol === 'min') { + return f({ ...baza, ...rez({ c: CLS.V, qv: true, motiv: 'TLS 1.2 is allowed; TLS 1.2 has no standardized post-quantum key exchange, so a peer that negotiates it uses classical (EC)DHE or RSA key exchange.', rec: REC.TLS12, q: 0 }) }); + } + return f({ ...baza, ...rez({ c: CLS.V, qv: true, motiv: `TLS 1.2 ${rol === 'max' ? 'is the maximum version' : 'is selected'}; TLS 1.2 has no standardized post-quantum key exchange, so key exchange is classical (EC)DHE or RSA.`, rec: REC.TLS12, q: 0 }) }); + } + if (v === '1.3') { + if (rol === 'max') { + return f({ ...baza, ...rez({ c: CLS.U, motiv: 'TLS 1.3 is the maximum version; the minimum and the key-exchange groups are not visible here.', rec: REC.TLS13 }) }); + } + return f({ ...baza, ...rez({ c: CLS.U, motiv: 'TLS 1.3 only: whether key exchange is post-quantum depends on the negotiated group (X25519MLKEM768 or a classical group), which depends on runtime defaults and the peer.', rec: REC.TLS13 }) }); + } + return f({ ...baza, ...rez({ c: CLS.U, motiv: 'Protocol version is negotiated at run time from the runtime defaults; not visible statically.', rec: REC.TLS13 }) }); +} + +// grupurile de schimb de chei TLS (ecdhCurve, CurvePreferences, jdk.tls.namedGroups) +export function grupTls(nume) { + const t = String(nume).trim(); + if (/^(X25519MLKEM768|X25519Kyber768Draft00|SecP256r1MLKEM768|SecP384r1MLKEM1024|p256_mlkem768|p384_mlkem1024|x25519_mlkem768)$/i.test(t)) { + if (/kyber/i.test(t)) return { grup: 'PREPQ', nume: t, primitiv: 'kem' }; + return { grup: 'HYBRID-KEX', nume: t }; + } + if (/^(X25519|x25519)$/.test(t)) return { grup: 'XDH', nume: 'X25519' }; + if (/^(X448|x448)$/.test(t)) return { grup: 'XDH', nume: 'X448' }; + if (/^(MLKEM(512|768|1024)|mlkem(512|768|1024))$/i.test(t)) return { grup: 'MLKEM', param: t.replace(/\D/g, '') }; + if (/^(ffdhe\d+)$/i.test(t)) return { grup: 'DH', param: t.toLowerCase() }; + const c = curbaCanonica(t); + if (c && /^secp|^brainpool/.test(c)) return { grup: 'ECDH', curba: c }; + if (/^auto$/i.test(t)) return { grup: 'UNKNOWN', motiv: 'ecdhCurve "auto" selects groups from the runtime defaults; not visible statically.' }; + return { grup: 'UNKNOWN', motiv: `Key-exchange group "${t}" is not in this tool's catalog.` }; +} + +// algoritmii JWS (RFC 7518 + RFC 8037 + RFC 8812) +export function jws(alg) { + const m = /^(HS|RS|PS|ES)(256|384|512)$/.exec(alg); + if (m) { + const hash = 'SHA' + m[2]; + if (m[1] === 'HS') return { grup: 'JWT-HMAC', nume: `JWS ${alg}`, hash, context: 'jwt' }; + if (m[1] === 'RS') return { grup: 'RSA', nume: `JWS ${alg}`, primitiv: 'signature', padding: 'pkcs1v15', hash, context: 'jwt' }; + if (m[1] === 'PS') return { grup: 'RSA', nume: `JWS ${alg}`, primitiv: 'signature', padding: 'other', hash, context: 'jwt' }; + const curba = m[2] === '256' ? 'secp256r1' : m[2] === '384' ? 'secp384r1' : 'secp521r1'; + return { grup: 'ECDSA', nume: `JWS ${alg}`, curba, hash, context: 'jwt' }; + } + if (alg === 'ES256K') return { grup: 'ECDSA', nume: 'JWS ES256K', curba: 'secp256k1', context: 'jwt' }; + if (alg === 'EdDSA' || alg === 'Ed25519') return { grup: 'EDDSA', nume: `JWS ${alg}`, context: 'jwt' }; + if (alg === 'none') return { grup: 'JWT-NONE' }; + return null; +} +export const JWS_RE = /^(?:(?:HS|RS|PS|ES)(?:256|384|512)|ES256K|EdDSA|Ed25519|none)$/; diff --git a/crypto-inventory/lib/cbom.mjs b/crypto-inventory/lib/cbom.mjs new file mode 100644 index 0000000..54758c5 --- /dev/null +++ b/crypto-inventory/lib/cbom.mjs @@ -0,0 +1,158 @@ +// Constructia CBOM-ului CycloneDX 1.6 din rezultatul scanarii. +import { createHash, randomUUID } from 'node:crypto'; + +export const ENUM = Object.freeze({ + componentType: ['application', 'framework', 'library', 'container', 'platform', 'operating-system', 'device', 'device-driver', 'firmware', 'file', 'machine-learning-model', 'data', 'cryptographic-asset'], + assetType: ['algorithm', 'certificate', 'protocol', 'related-crypto-material'], + primitive: ['drbg', 'mac', 'block-cipher', 'stream-cipher', 'signature', 'hash', 'pke', 'xof', 'kdf', 'key-agree', 'kem', 'ae', 'combiner', 'other', 'unknown'], + mode: ['cbc', 'ecb', 'ccm', 'gcm', 'cfb', 'ofb', 'ctr', 'other', 'unknown'], + padding: ['pkcs5', 'pkcs7', 'pkcs1v15', 'oaep', 'raw', 'other', 'unknown'], + cryptoFunctions: ['generate', 'keygen', 'encrypt', 'decrypt', 'digest', 'tag', 'keyderive', 'sign', 'verify', 'encapsulate', 'decapsulate', 'other', 'unknown'], + protocolType: ['tls', 'ssh', 'ipsec', 'ike', 'sstp', 'wpa', 'other', 'unknown'], + materialType: ['private-key', 'public-key', 'secret-key', 'key', 'ciphertext', 'signature', 'digest', 'initialization-vector', 'nonce', 'seed', 'salt', 'shared-secret', 'tag', 'additional-data', 'password', 'credential', 'token', 'other', 'unknown'], +}); + +const P = 'aere:crypto-inventory:'; + +function slug(s) { + return String(s).toLowerCase().replace(/[^a-z0-9.+-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'x'; +} + +function cheieComponenta(g) { + return [g.assetType, g.name, g.primitive, g.classification, g.parameterSetIdentifier, g.curve, g.mode, g.padding, + g.protocolType, g.protocolVersion, g.material && g.material.type, g.certificate && g.certificate.fingerprintSha256, g.reason].map((x) => x ?? '').join('|'); +} + +function uuidDin(hex) { + const h = hex.slice(0, 32).split(''); + h[12] = '5'; + h[16] = '89ab'[parseInt(h[16], 16) & 3]; + const s = h.join(''); + return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`; +} + +export function construiesteCbom(rez, optiuni = {}) { + const grupuri = new Map(); + for (const g of rez.findings) { + const k = cheieComponenta(g); + if (!grupuri.has(k)) grupuri.set(k, []); + grupuri.get(k).push(g); + } + const componente = []; + for (const [k, lista] of grupuri) { + const g = lista[0]; + const ref = `crypto:${g.assetType}:${slug(g.certificate ? 'x509-' + (g.certificate.subjectName || 'certificate') : g.name)}:${createHash('sha256').update(k).digest('hex').slice(0, 12)}`; + const cp = { assetType: g.assetType }; + if (g.assetType === 'algorithm') { + const ap = { primitive: ENUM.primitive.includes(g.primitive) ? g.primitive : 'unknown' }; + if (g.parameterSetIdentifier) ap.parameterSetIdentifier = g.parameterSetIdentifier; + if (g.curve) ap.curve = g.curve; + if (g.mode) ap.mode = ENUM.mode.includes(g.mode) ? g.mode : 'other'; + if (g.padding) ap.padding = ENUM.padding.includes(g.padding) ? g.padding : 'other'; + const f = [...new Set(lista.flatMap((x) => x.cryptoFunctions || []))].filter((x) => ENUM.cryptoFunctions.includes(x)).sort(); + if (f.length) ap.cryptoFunctions = f; + if (Number.isInteger(g.classicalSecurityLevel)) ap.classicalSecurityLevel = g.classicalSecurityLevel; + if (Number.isInteger(g.nistQuantumSecurityLevel)) ap.nistQuantumSecurityLevel = g.nistQuantumSecurityLevel; + cp.algorithmProperties = ap; + } else if (g.assetType === 'protocol') { + cp.protocolProperties = { type: ENUM.protocolType.includes(g.protocolType) ? g.protocolType : 'unknown' }; + if (g.protocolVersion) cp.protocolProperties.version = g.protocolVersion; + } else if (g.assetType === 'related-crypto-material') { + const m = { type: ENUM.materialType.includes(g.material && g.material.type) ? g.material.type : 'unknown' }; + if (g.material && g.material.format) m.format = g.material.format; + if (g.parameterSetIdentifier && /^\d+$/.test(g.parameterSetIdentifier)) m.size = Number(g.parameterSetIdentifier); + cp.relatedCryptoMaterialProperties = m; + } else if (g.assetType === 'certificate') { + const c = g.certificate || {}; + const cert = {}; + for (const camp of ['subjectName', 'issuerName', 'notValidBefore', 'notValidAfter', 'certificateFormat', 'certificateExtension']) if (c[camp]) cert[camp] = c[camp]; + cp.certificateProperties = cert; + } + if (g.oid) cp.oid = g.oid; + const props = [ + { name: `${P}classification`, value: g.classification }, + { name: `${P}quantum-vulnerable`, value: String(!!g.quantumVulnerable) }, + { name: `${P}reason`, value: g.reason }, + ]; + if (g.recommendation) props.push({ name: `${P}recommendation`, value: g.recommendation }); + if (g.assetType !== 'algorithm') props.push({ name: `${P}key-algorithm`, value: g.name }); + if (g.certificate && g.certificate.fingerprintSha256) props.push({ name: `${P}certificate-sha256-fingerprint`, value: g.certificate.fingerprintSha256 }); + props.push({ name: `${P}languages`, value: [...new Set(lista.map((x) => x.language))].sort().join(',') }); + props.push({ name: `${P}evidence-kinds`, value: [...new Set(lista.map((x) => x.evidenceKind))].sort().join(',') }); + const rezolvate = [...new Set(lista.filter((x) => x.resolvedFrom).map((x) => `${x.file}:${x.line} <- ${x.resolvedFrom}`))]; + if (rezolvate.length) props.push({ name: `${P}resolved-from`, value: rezolvate.join('; ') }); + const nume = g.assetType === 'certificate' ? `X.509 certificate${g.certificate && g.certificate.subjectName ? ' ' + g.certificate.subjectName : ''}` + : g.assetType === 'related-crypto-material' ? `${g.name} ${g.material ? g.material.type : 'key material'}` : g.name; + componente.push({ + type: 'cryptographic-asset', + 'bom-ref': ref, + name: nume, + cryptoProperties: cp, + evidence: { + occurrences: lista.map((x) => { + const oc = { location: x.file, line: x.line, symbol: x.api }; + if (x.context) oc.additionalContext = x.context; + return oc; + }), + }, + properties: props, + }); + } + // bibliotecile declarate, unite pe (ecosistem, nume, versiune) + const libs = new Map(); + for (const b of rez.libraries) { + const k = `${b.ecosystem}|${b.name}|${b.version || ''}`; + if (!libs.has(k)) libs.set(k, []); + libs.get(k).push(b); + } + for (const [k, lista] of libs) { + const b = lista[0]; + const c = { + type: 'library', + 'bom-ref': `library:${b.ecosystem}:${slug(b.name)}:${createHash('sha256').update(k).digest('hex').slice(0, 12)}`, + name: b.name, + }; + if (b.version) c.version = b.version; + if (b.purl) c.purl = b.purl; + c.evidence = { occurrences: lista.map((x) => ({ location: x.file, line: x.line })) }; + c.properties = [ + { name: `${P}declared-only`, value: 'true' }, + { name: `${P}note`, value: 'Declared in a dependency manifest. Declaration is not use: see the cryptographic-asset components for code that calls it.' }, + { name: `${P}provides`, value: b.provides }, + ]; + componente.push(c); + } + componente.sort((a, b) => (a['bom-ref'] < b['bom-ref'] ? -1 : a['bom-ref'] > b['bom-ref'] ? 1 : 0)); + + const s = rez.stats; + const metaProps = [ + ['files-seen', s.filesSeen], ['code-files-analyzed', s.codeFilesTotal], ['text-files-pem-only', s.textFilesPemOnly], + ['not-read-binary', s.notRead.binary], ['not-read-too-large', s.notRead.tooLarge], ['not-read-file-limit', s.notRead.fileLimit], + ['not-read-unreadable', s.notRead.unreadable], ['symlinks-not-followed', s.symlinksNotFollowed], + ['files-over-resolution-limit', s.resolutionLimitFiles], ['max-resolved-variables-per-file', rez.options.maxResolvedVariablesPerFile], + ['dirs-excluded', Object.entries(s.dirsExcluded).map(([n, c]) => `${n}:${c}`).join(',') || 'none'], + ['max-file-bytes', rez.options.maxFileBytes], ['max-files', rez.options.maxFiles], + ['method', 'static pattern analysis of source text; no code from the scanned tree is executed'], + ].map(([n, v]) => ({ name: `${P}${n}`, value: String(v) })); + + const bom = { + $schema: 'http://cyclonedx.org/schema/bom-1.6.schema.json', + bomFormat: 'CycloneDX', + specVersion: '1.6', + serialNumber: '', + version: 1, + metadata: { + tools: { components: [{ type: 'application', name: rez.tool.name, version: rez.tool.version, description: 'Static cryptographic inventory of source code (CBOM)' }] }, + component: { type: 'application', 'bom-ref': 'scanned-target', name: rez.rootName }, + properties: metaProps, + }, + components: componente, + }; + if (optiuni.deterministic) { + bom.serialNumber = `urn:uuid:${uuidDin(createHash('sha256').update(JSON.stringify(componente)).digest('hex'))}`; + } else { + bom.metadata = { timestamp: rez.finishedAt, ...bom.metadata }; + bom.serialNumber = `urn:uuid:${randomUUID()}`; + } + return bom; +} diff --git a/crypto-inventory/lib/context.mjs b/crypto-inventory/lib/context.mjs new file mode 100644 index 0000000..9c6a98b --- /dev/null +++ b/crypto-inventory/lib/context.mjs @@ -0,0 +1,253 @@ +// Contextul unui fisier scanat si uneltele comune ale detectorilor. +import { curata, inSir, inceputuriDeRand, randul } from './lexer.mjs'; +import { evalueaza } from './catalog.mjs'; + +export function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function facContext(text, limbaj, rel) { + const { code, siruri } = curata(text, limbaj); + const inceputuri = inceputuriDeRand(text); + const ctx = { + text, code, siruri, limbaj, rel, + gasiri: [], + inSir: (pos) => inSir(siruri, pos), + poz: (pos) => randul(inceputuri, pos), + // potriviri in cod: numele API-ului (inceputul potrivirii) nu are voie sa fie intr-un sir + potriviri(re) { + const r = []; + const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'); + for (const m of code.matchAll(g)) if (!inSir(siruri, m.index)) r.push(m); + return r; + }, + adauga(pos, api, fel, activ, extra = {}) { + const ev = evalueaza(activ); + const { line, column } = randul(inceputuri, pos); + const bucata = extra.bucata !== undefined ? extra.bucata : ''; + ctx.gasiri.push({ + file: rel, + line, + column, + language: limbaj, + api, + evidenceKind: fel, + assetType: activ.assetType || (ev.protocolType ? 'protocol' : 'algorithm'), + group: ev.grup, + name: ev.nume || activ.nume || api, + primitive: ev.primitiv || 'unknown', + parameterSetIdentifier: ev.param !== undefined && ev.param !== null && ev.param !== '' ? String(ev.param) : undefined, + curve: ev.curba || undefined, + mode: ev.mod || undefined, + padding: ev.padding || activ.padding || undefined, + cryptoFunctions: activ.functii || undefined, + classification: ev.classification, + quantumVulnerable: ev.quantumVulnerable, + reason: activ.nota && !ev.reason.includes(activ.nota) ? `${ev.reason} ${activ.nota}` : ev.reason, + recommendation: ev.recommendation, + nistQuantumSecurityLevel: ev.nistQuantumSecurityLevel, + classicalSecurityLevel: ev.classicalSecurityLevel, + oid: ev.oid, + protocolType: ev.protocolType, + protocolVersion: ev.protocolVersion, + suppressedBy: extra.suprimaDe || undefined, + resolvedFrom: extra.rezolvatDin || undefined, + context: normalizeaza(bucata), + material: activ.material || undefined, + certificate: activ.certificat || undefined, + }); + }, + }; + return ctx; +} + +function normalizeaza(s) { + const t = String(s || '').replace(/\s+/g, ' ').trim(); + return t.length > 120 ? t.slice(0, 117) + '...' : t; +} + +// Argumentele unui apel: de la pozitia de dupa '(' pana la ')' echilibrata. +// Intoarce textul argumentelor (din vederea de cod) si limitele lui. +export function argumente(ctx, dupaParanteza) { + const c = ctx.code; + let adancime = 1; + let j = dupaParanteza; + while (j < c.length) { + if (ctx.inSir(j)) { j++; continue; } + const ch = c[j]; + if (ch === '(' || ch === '[' || ch === '{') adancime++; + else if (ch === ')' || ch === ']' || ch === '}') { adancime--; if (adancime === 0) break; } + j++; + if (j - dupaParanteza > 4000) break; + } + return { text: c.slice(dupaParanteza, j), start: dupaParanteza, end: j }; +} + +// imparte argumentele pe virgulele de nivel zero +export function imparteArgumente(ctx, start, end) { + const c = ctx.code; + const r = []; + let adancime = 0; + let s = start; + for (let j = start; j < end; j++) { + if (ctx.inSir(j)) continue; + const ch = c[j]; + if (ch === '(' || ch === '[' || ch === '{') adancime++; + else if (ch === ')' || ch === ']' || ch === '}') adancime--; + else if (ch === ',' && adancime === 0) { r.push({ text: c.slice(s, j), start: s }); s = j + 1; } + } + if (end > s) r.push({ text: c.slice(s, end), start: s }); + return r.map((a) => { + const lead = a.text.length - a.text.trimStart().length; + return { text: a.text.trim(), start: a.start + lead }; + }).filter((a) => a.text.length); +} + +// un literal de sir simplu, fara interpolare; intoarce valoarea sau null +export function literal(s) { + const t = String(s).trim(); + const m = /^(?:[rRbBuU]{0,2})(['"`])([^'"`\n$\\]*)\1$/.exec(t); + return m ? m[2] : null; +} + +// identificator simplu (nu proprietate, nu apel) +export function identificator(s) { + const t = String(s).trim(); + return /^[A-Za-z_$][\w$]*$/.test(t) ? t : null; +} + +// Rezolvarea conservatoare a unei variabile la un literal: EXACT o atribuire in fisier, +// sub forma unei declaratii cu literal, si numele nu apare ca parametru de functie. +// Orice alta forma: null (gasirea iese "unknown", nu se ghiceste). +// Rezultatul depinde numai de fisier si de nume, deci se tine minte; si cel mult REZOLVARI_PE_FISIER nume distincte se rezolva +// intr-un fisier (2026-09-29, revizuirea adversariala): fiecare rezolvare citeste tot fisierul, deci un fisier cu mii de apeluri +// pe variabile costa patratic. Un nume peste plafon iese "unknown", si fisierul se numara in statistici (nu se sare in tacere). +export const REZOLVARI_PE_FISIER = 64; +export function rezolva(ctx, nume) { + if (!identificator(nume)) return null; + if (!ctx.rezolvari) ctx.rezolvari = new Map(); + if (ctx.rezolvari.has(nume)) return ctx.rezolvari.get(nume); + if (ctx.rezolvari.size >= REZOLVARI_PE_FISIER) { ctx.rezolvariPestePlafon = (ctx.rezolvariPestePlafon || 0) + 1; return null; } + const r = rezolvaOData(ctx, nume); + ctx.rezolvari.set(nume, r); + return r; +} + +function rezolvaOData(ctx, nume) { + const c = ctx.code; + const e = escapeRe(nume); + const atribuiri = ctx.potriviri(new RegExp(`(?])`, 'g')); + if (atribuiri.length !== 1) return null; + const k = atribuiri[0].index; + const ls0 = c.lastIndexOf('\n', k) + 1; + const le0 = c.indexOf('\n', k) < 0 ? c.length : c.indexOf('\n', k); + const linie = c.slice(ls0, le0); + let parametru = false; + if (ctx.limbaj === 'javascript') { + // intervalele fara capat ([^(]*, [^:]*, ...) sunt marginite: pornite de la fiecare cuvant cheie, ar citi pana la capatul + // fisierului pe un text fara paranteza (sau doua puncte) inchisa, deci un fisier facut anume ar costa patratic + parametru = new RegExp(`(?:function\\b[^(]{0,200}\\(|\\()([^()]{0,2000}(?|\\{)`).test(c) + || new RegExp(`\\bfor\\s*\\(\\s*(?:const|let|var)?\\s*${e}\\s+(?:of|in)\\b`).test(c) + || new RegExp(`(?`).test(c); + const m = new RegExp(`\\b(?:const|let|var)\\s+${e}\\s*=\\s*(['"\`])([^'"\`\\n$\\\\]*)\\1\\s*[;,]?\\s*$`).exec(linie); + return !parametru && m ? { valoare: m[2], line: ctx.poz(k).line } : null; + } + if (ctx.limbaj === 'python') { + parametru = new RegExp(`\\bdef\\s+\\w+\\s*\\([^)]{0,2000}(? 60 ? arg.text.slice(0, 57) + '...' : arg.text }; +} + +export function necunoscut(expr, api) { + return { grup: 'UNKNOWN', motiv: `Algorithm for ${api} is given by the expression "${expr}", which this tool does not resolve statically (it resolves only a variable with exactly one literal assignment in this file, for at most ${REZOLVARI_PE_FISIER} distinct variables per file).` }; +} + +// Obiectul literal care contine pozitia (acolade echilibrate), pentru a citi parametri vecini. +export function obiectulDin(ctx, pos) { + const c = ctx.code; + let adancime = 0; + let s = pos; + while (s > 0 && pos - s < 2000) { + s--; + if (ctx.inSir(s)) continue; + if (c[s] === '}') adancime++; + else if (c[s] === '{') { if (adancime === 0) break; adancime--; } + } + if (c[s] !== '{') return null; + const a = argumente(ctx, s + 1); + return { text: c.slice(s, a.end + 1), start: s, end: a.end + 1 }; +} + +// Suprimarea: o gasire din import cade daca acelasi fisier are o gasire din apel care o acopera. +// Pentru hash-uri si MAC se compara numele intreg, pentru cifruri si KDF familia, altfel grupul. +function tokeni(g) { + if (g.group === 'HASH') return [`HASH:${g.name}`]; + if (g.group === 'MAC') return g.name.startsWith('HMAC-') ? [`MAC:${g.name}`, `HASH:${g.name.slice(5)}`] : [`MAC:${g.name}`]; + if (g.group === 'CIPHER' || g.group === 'KDF') return [`${g.group}:${g.name.split('-')[0]}`]; + return [g.group]; +} + +export function aplicaSuprimarea(gasiri) { + const acoperite = new Set(); + for (const g of gasiri) if (g.evidenceKind !== 'import') { acoperite.add(g.group); for (const t of tokeni(g)) acoperite.add(t); } + const r = gasiri.filter((g) => { + if (g.evidenceKind !== 'import' || !g.suppressedBy) return true; + const lista = g.suppressedBy === 'AUTO' ? tokeni(g) : g.suppressedBy; + return !lista.some((x) => acoperite.has(x)); + }); + // dubluri: acelasi rand, acelasi nume, acelasi API + const vazut = new Set(); + return r.filter((g) => { + const k = `${g.line}|${g.name}|${g.api}|${g.classification}`; + if (vazut.has(k)) return false; + vazut.add(k); + return true; + }).map((g) => { const o = { ...g }; delete o.suppressedBy; return o; }); +} + +// numele unui cifru in stil OpenSSL (node:crypto): aes-256-gcm, des-ede3-cbc, bf-cbc, rc4, chacha20-poly1305 +export function cifruOpenssl(s) { + const t = String(s).toLowerCase().trim(); + let m; + if ((m = /^(?:id-)?aes-?(128|192|256)(?:-(\w+(?:-\w+)?))?$/.exec(t))) return { grup: 'CIPHER', nume: 'AES', param: m[1], mod: m[2] || 'cbc' }; + if ((m = /^(camellia|aria|sm4)-?(128|192|256)?(?:-(\w+))?$/.exec(t))) return { grup: 'CIPHER', nume: m[1].toUpperCase(), param: m[2], mod: m[3] }; + if (/^des-ede3(-\w+)?$|^des3$|^des-ede(-\w+)?$/.test(t)) return { grup: 'CIPHER', nume: '3DES', mod: (/-(cbc|ecb|cfb|ofb)$/.exec(t) || [])[1] || (t === 'des-ede3' || t === 'des-ede' ? 'ecb' : 'cbc') }; + if ((m = /^des(?:-(\w+))?$/.exec(t))) return { grup: 'CIPHER', nume: 'DES', mod: m[1] || 'cbc' }; + if (/^rc4(-\d+)?$|^rc4-hmac-md5$/.test(t)) return { grup: 'CIPHER', nume: 'RC4' }; + if (/^rc2(-\w+)?$/.test(t)) return { grup: 'CIPHER', nume: 'RC2' }; + if ((m = /^(?:bf|blowfish)(?:-(\w+))?$/.exec(t))) return { grup: 'CIPHER', nume: 'Blowfish', mod: m[1] }; + if ((m = /^(cast5|cast|idea|seed)(?:-(\w+))?$/.exec(t))) return { grup: 'CIPHER', nume: m[1].toUpperCase(), mod: m[2] }; + if (t === 'chacha20-poly1305') return { grup: 'CIPHER', nume: 'ChaCha20-Poly1305' }; + if (t === 'chacha20') return { grup: 'CIPHER', nume: 'ChaCha20' }; + return null; +} diff --git a/crypto-inventory/lib/detect-go.mjs b/crypto-inventory/lib/detect-go.mjs new file mode 100644 index 0000000..ec983b4 --- /dev/null +++ b/crypto-inventory/lib/detect-go.mjs @@ -0,0 +1,207 @@ +// Detectorul Go: importurile pachetelor crypto/* (in Go un import nefolosit nu compileaza, deci +// importul e o dovada de folosire, cu exceptia importului gol "_"), apelurile cu parametri si +// configuratia tls.Config. +import { argumente, imparteArgumente, escapeRe } from './context.mjs'; +import { grupTls, jws } from './catalog.mjs'; + +const PACHETE = { + 'crypto/rsa': { grup: 'RSA' }, + 'crypto/dsa': { grup: 'DSA' }, + 'crypto/ecdsa': { grup: 'ECDSA' }, + 'crypto/elliptic': { grup: 'EC' }, + 'crypto/ecdh': { grup: 'ECDH' }, + 'crypto/ed25519': { grup: 'EDDSA', nume: 'Ed25519' }, + 'golang.org/x/crypto/ed25519': { grup: 'EDDSA', nume: 'Ed25519' }, + 'golang.org/x/crypto/curve25519': { grup: 'XDH', nume: 'X25519' }, + 'crypto/md5': { grup: 'HASH', hash: 'MD5' }, + 'crypto/sha1': { grup: 'HASH', hash: 'SHA1' }, + 'golang.org/x/crypto/md4': { grup: 'HASH', hash: 'MD4' }, + 'golang.org/x/crypto/ripemd160': { grup: 'HASH', hash: 'RIPEMD160' }, + 'crypto/sha256': { grup: 'HASH', hash: 'SHA256' }, + 'crypto/sha512': { grup: 'HASH', hash: 'SHA512' }, + 'crypto/sha3': { grup: 'HASH', hash: 'SHA3-256' }, + 'golang.org/x/crypto/sha3': { grup: 'HASH', hash: 'SHA3-256' }, + 'crypto/des': { grup: 'CIPHER', nume: 'DES' }, + 'crypto/rc4': { grup: 'CIPHER', nume: 'RC4' }, + 'golang.org/x/crypto/blowfish': { grup: 'CIPHER', nume: 'Blowfish' }, + 'crypto/aes': { grup: 'CIPHER', nume: 'AES' }, + 'golang.org/x/crypto/chacha20poly1305': { grup: 'CIPHER', nume: 'ChaCha20-Poly1305' }, + 'crypto/mlkem': { grup: 'MLKEM' }, + 'golang.org/x/crypto/bcrypt': { grup: 'KDF', nume: 'bcrypt' }, + 'golang.org/x/crypto/argon2': { grup: 'KDF', nume: 'Argon2' }, + 'golang.org/x/crypto/scrypt': { grup: 'KDF', nume: 'scrypt' }, + 'golang.org/x/crypto/pbkdf2': { grup: 'KDF', nume: 'PBKDF2' }, + 'crypto/pbkdf2': { grup: 'KDF', nume: 'PBKDF2' }, + 'crypto/hkdf': { grup: 'KDF', nume: 'HKDF' }, + 'golang.org/x/crypto/ssh': { grup: 'SSH' }, + 'github.com/ethereum/go-ethereum/crypto': { grup: 'SECP256K1', nota: 'go-ethereum crypto package: accounts and signatures are secp256k1 ECDSA.' }, + 'github.com/ethereum/go-ethereum/crypto/secp256k1': { grup: 'SECP256K1' }, + 'github.com/decred/dcrd/dcrec/secp256k1': { grup: 'SECP256K1' }, + 'github.com/btcsuite/btcd/btcec': { grup: 'SECP256K1' }, + 'github.com/cloudflare/circl/sign/ed25519': { grup: 'EDDSA', nume: 'Ed25519' }, + 'github.com/cloudflare/circl/sign/slhdsa': { grup: 'SLHDSA' }, + 'github.com/cloudflare/circl/kem/hybrid': { grup: 'HYBRID-KEX', nume: 'hybrid KEM (circl)' }, +}; + +function pachetActiv(cale) { + if (PACHETE[cale]) return PACHETE[cale]; + let m; + if ((m = /^github\.com\/cloudflare\/circl\/sign\/mldsa\/mldsa(44|65|87)$/.exec(cale))) return { grup: 'MLDSA', param: m[1] }; + if ((m = /^github\.com\/cloudflare\/circl\/kem\/mlkem\/mlkem(512|768|1024)$/.exec(cale))) return { grup: 'MLKEM', param: m[1] }; + if ((m = /^github\.com\/cloudflare\/circl\/sign\/dilithium\/mode(2|3|5)$/.exec(cale))) return { grup: 'PREPQ', nume: `Dilithium${m[1]}`, primitiv: 'signature' }; + if ((m = /^github\.com\/cloudflare\/circl\/kem\/kyber\/kyber(512|768|1024)$/.exec(cale))) return { grup: 'PREPQ', nume: `Kyber${m[1]}`, primitiv: 'kem' }; + if (/^github\.com\/btcsuite\/btcd\/btcec(\/v\d+)?$/.test(cale) || /^github\.com\/decred\/dcrd\/dcrec\/secp256k1(\/v\d+)?$/.test(cale)) return { grup: 'SECP256K1' }; + if (/^github\.com\/golang-jwt\/jwt(\/v\d+)?$/.test(cale)) return { grup: 'LIB-MULTI', nume: 'golang-jwt', motiv: 'Imports golang-jwt; no SigningMethod reference recognized in this file.' }; + if (cale === 'crypto/tls') return { grup: 'TLS', param: 'negotiated', motiv: 'crypto/tls with default settings' }; + return null; +} + +export function importuriGo(ctx) { + const r = []; + const c = ctx.code; + const adauga = (alias, cale, pos) => r.push({ alias: alias || cale.split('/').filter((x) => !/^v\d+$/.test(x)).pop(), gol: alias === '_', cale, pos }); + for (const m of c.matchAll(/(^|\n)[ \t]*import[ \t]+(?:([\w.]+)[ \t]+)?("([^"\n]+)")/g)) { + adauga(m[2], m[4], m.index + m[0].indexOf(m[3])); + } + for (const m of c.matchAll(/(^|\n)[ \t]*import[ \t]*\(([^)]*)\)/g)) { + const bloc = m[2]; + const baza = m.index + m[0].indexOf('(') + 1; + for (const x of bloc.matchAll(/(?:^|\n)[ \t]*(?:([\w.]+)[ \t]+)?("([^"\n]+)")/g)) { + adauga(x[1], x[3], baza + x.index + x[0].indexOf(x[2])); + } + } + return r; +} + +export function detecteazaGo(ctx) { + const imp = importuriGo(ctx); + const alias = (cale) => { const i = imp.find((x) => x.cale === cale && !x.gol); return i ? escapeRe(i.alias) : null; }; + const apel = (cale, functii) => { + const a = alias(cale); + if (!a) return []; + return ctx.potriviri(new RegExp(`(? { + const dupa = ctx.code.slice(m.index + m[0].length); + const p = /^\s*\(/.exec(dupa); + if (!p) return { text: '', parti: [] }; + const a = argumente(ctx, m.index + m[0].length + p[0].length); + return { ...a, parti: imparteArgumente(ctx, a.start, a.end) }; + }; + const pachetHash = { md5: 'MD5', sha1: 'SHA1', sha256: 'SHA256', sha512: 'SHA512', sha3: 'SHA3-256' }; + + // apelurile cu parametri + for (const m of apel('crypto/rsa', 'GenerateKey|GenerateMultiPrimeKey')) { + const a = args(m); + const ult = a.parti[a.parti.length - 1]; + ctx.adauga(m.index, `rsa.${m[1]}`, 'call', { grup: 'RSA', param: ult && /^\d+$/.test(ult.text) ? ult.text : undefined, functii: ['keygen'] }, { bucata: m[0] + (ult ? ult.text : '') }); + } + for (const m of apel('crypto/rsa', 'EncryptOAEP|DecryptOAEP|EncryptPKCS1v15|DecryptPKCS1v15|SignPSS|SignPKCS1v15|VerifyPSS|VerifyPKCS1v15')) { + const a = args(m); + const h = /\bcrypto\s*\.\s*(MD5|SHA1|SHA224|SHA256|SHA384|SHA512)\b/.exec(a.text); + const sig = /^(Sign|Verify)/.test(m[1]); + ctx.adauga(m.index, `rsa.${m[1]}`, 'call', { grup: 'RSA', primitiv: sig ? 'signature' : 'pke', padding: /OAEP/.test(m[1]) ? 'oaep' : /PKCS1v15/.test(m[1]) ? 'pkcs1v15' : 'other', hash: h ? h[1] : undefined, functii: [/^Sign/.test(m[1]) ? 'sign' : /^Verify/.test(m[1]) ? 'verify' : /^Encrypt/.test(m[1]) ? 'encrypt' : 'decrypt'] }, { bucata: m[0] }); + } + const ell = alias('crypto/elliptic'); + const consumate = new Set(); + for (const m of apel('crypto/ecdsa', 'GenerateKey|Sign|SignASN1|Verify|VerifyASN1')) { + const a = args(m); + let curba; + if (ell && m[1] === 'GenerateKey') { + const k = new RegExp(`${ell}\\s*\\.\\s*(P224|P256|P384|P521)\\s*\\(`).exec(a.text); + if (k) { curba = k[1]; consumate.add(a.start + k.index); } + } + ctx.adauga(m.index, `ecdsa.${m[1]}`, 'call', { grup: 'ECDSA', curba, functii: [m[1] === 'GenerateKey' ? 'keygen' : /^Sign/.test(m[1]) ? 'sign' : 'verify'] }, { bucata: m[0] + (curba ? ` ${curba}` : '') }); + } + for (const m of apel('crypto/elliptic', 'P224|P256|P384|P521')) { + if (consumate.has(m.index)) continue; + ctx.adauga(m.index, `elliptic.${m[1]}`, 'call', { grup: 'EC', curba: m[1] }, { bucata: m[0] }); + } + for (const m of apel('crypto/ecdh', 'X25519|P256|P384|P521')) { + ctx.adauga(m.index, `ecdh.${m[1]}`, 'call', m[1] === 'X25519' ? { grup: 'XDH', nume: 'X25519', functii: ['keygen'] } : { grup: 'ECDH', curba: m[1], functii: ['keygen'] }, { bucata: m[0] }); + } + for (const cale of ['crypto/ed25519', 'golang.org/x/crypto/ed25519']) { + for (const m of apel(cale, 'GenerateKey|Sign|Verify|NewKeyFromSeed|VerifyWithOptions')) { + ctx.adauga(m.index, `ed25519.${m[1]}`, 'call', { grup: 'EDDSA', nume: 'Ed25519', functii: [m[1] === 'Sign' ? 'sign' : /^Verify/.test(m[1]) ? 'verify' : 'keygen'] }, { bucata: m[0] }); + } + } + for (const [cale, fn] of [['crypto/md5', 'New|Sum'], ['crypto/sha1', 'New|Sum'], ['crypto/sha256', 'New|New224|Sum256|Sum224'], ['crypto/sha512', 'New|New384|Sum512|Sum384|New512_256'], ['crypto/sha3', 'New256|New384|New512|Sum256|Sum384|Sum512|NewSHAKE128|NewSHAKE256'], ['golang.org/x/crypto/sha3', 'New256|New384|New512|Sum256|Sum384|Sum512|NewLegacyKeccak256']]) { + for (const m of apel(cale, fn)) { + const dupa = ctx.code.slice(m.index + m[0].length, m.index + m[0].length + 3); + if (!/^\s*\(/.test(dupa)) continue; // referinta de functie (de ex. hmac.New(sha1.New, ...)) se raporteaza la hmac + const pk = cale.split('/').pop(); + let h = pachetHash[pk]; + if (/224/.test(m[1])) h = 'SHA224'; + if (/384/.test(m[1])) h = pk === 'sha3' ? 'SHA3-384' : 'SHA384'; + if (/512_256/.test(m[1])) h = 'SHA512/256'; + if (pk === 'sha3' && /512/.test(m[1])) h = 'SHA3-512'; + if (/SHAKE128/.test(m[1])) h = 'SHAKE128'; + if (/SHAKE256/.test(m[1])) h = 'SHAKE256'; + if (/Keccak/.test(m[1])) h = 'KECCAK256'; + ctx.adauga(m.index, `${pk}.${m[1]}`, 'call', { grup: 'HASH', hash: h, functii: ['digest'] }, { bucata: m[0] }); + } + } + for (const m of apel('crypto/hmac', 'New')) { + const a = args(m); + const k = /^(\w+)\s*\.\s*New(\d*)\b/.exec(a.parti[0] ? a.parti[0].text : ''); + const pk = k ? imp.find((x) => x.alias === k[1]) : null; + const baza = pk ? pachetHash[pk.cale.split('/').pop()] : null; + const h = baza ? (k[2] === '384' ? 'SHA384' : k[2] === '224' ? 'SHA224' : baza) : null; + ctx.adauga(m.index, 'hmac.New', 'call', h ? { grup: 'MAC', hash: h, functii: ['tag'] } : { grup: 'UNKNOWN', motiv: `hmac.New hash "${a.parti[0] ? a.parti[0].text : '?'}" cannot be resolved statically.` }, { bucata: m[0] + (a.parti[0] ? `(${a.parti[0].text}` : '') }); + } + for (const m of apel('crypto/des', 'NewCipher|NewTripleDESCipher')) { + ctx.adauga(m.index, `des.${m[1]}`, 'call', { grup: 'CIPHER', nume: m[1] === 'NewCipher' ? 'DES' : '3DES', functii: ['encrypt'] }, { bucata: m[0] }); + } + for (const m of apel('crypto/rc4', 'NewCipher')) ctx.adauga(m.index, 'rc4.NewCipher', 'call', { grup: 'CIPHER', nume: 'RC4', functii: ['encrypt'] }, { bucata: m[0] }); + for (const m of apel('crypto/aes', 'NewCipher')) ctx.adauga(m.index, 'aes.NewCipher', 'call', { grup: 'CIPHER', nume: 'AES', functii: ['encrypt'] }, { bucata: m[0] }); + for (const m of apel('crypto/mlkem', 'GenerateKey768|GenerateKey1024|NewEncapsulationKey768|NewEncapsulationKey1024|NewDecapsulationKey768|NewDecapsulationKey1024')) { + ctx.adauga(m.index, `mlkem.${m[1]}`, 'call', { grup: 'MLKEM', param: /1024/.test(m[1]) ? '1024' : '768', functii: [/^Generate/.test(m[1]) ? 'keygen' : 'other'] }, { bucata: m[0] }); + } + const eth = alias('github.com/ethereum/go-ethereum/crypto'); + if (eth) { + for (const m of ctx.potriviri(new RegExp(`(? /^github\.com\/golang-jwt\/jwt(\/v\d+)?$/.test(x.cale)); + if (jwtI) { + for (const m of ctx.potriviri(new RegExp(`(? (variabila ? new RegExp(`(? (x || '').trim()); + const alg = alg0.toUpperCase(); + const mod = mod0 ? mod0.toLowerCase() : undefined; + const pad = pad0 ? pad0.toUpperCase() : ''; + let m; + if (alg === 'RSA') { + const padding = /OAEP/.test(pad) ? 'oaep' : /PKCS1/.test(pad) ? 'pkcs1v15' : /NOPADDING/.test(pad) ? 'raw' : (pad ? 'other' : 'pkcs1v15'); + const nota = pad ? undefined : 'Cipher "RSA" without padding means RSA/ECB/PKCS1Padding in the standard JCA providers.'; + return { grup: 'RSA', primitiv: 'pke', padding, nota, functii: ['encrypt', 'decrypt'] }; + } + if ((m = /^AES(?:_(128|192|256))?$/.exec(alg))) { + if (!mod) return { grup: 'CIPHER', nume: 'AES', param: m[1], mod: 'ecb', nota: 'Cipher "AES" without mode and padding means AES/ECB/PKCS5Padding in the standard JCA providers.', functii: ['encrypt', 'decrypt'] }; + return { grup: 'CIPHER', nume: 'AES', param: m[1], mod: mod === 'nopadding' ? undefined : mod, functii: ['encrypt', 'decrypt'] }; + } + if (alg === 'DES') return { grup: 'CIPHER', nume: 'DES', mod, functii: ['encrypt', 'decrypt'] }; + if (alg === 'DESEDE' || alg === 'TRIPLEDES') return { grup: 'CIPHER', nume: '3DES', mod, functii: ['encrypt', 'decrypt'] }; + if (alg === 'RC4' || alg === 'ARCFOUR') return { grup: 'CIPHER', nume: 'RC4', functii: ['encrypt', 'decrypt'] }; + if (alg === 'RC2') return { grup: 'CIPHER', nume: 'RC2', functii: ['encrypt', 'decrypt'] }; + if (alg === 'BLOWFISH') return { grup: 'CIPHER', nume: 'Blowfish', mod, functii: ['encrypt', 'decrypt'] }; + if (alg === 'CHACHA20-POLY1305') return { grup: 'CIPHER', nume: 'ChaCha20-Poly1305', functii: ['encrypt', 'decrypt'] }; + if (alg === 'CHACHA20') return { grup: 'CIPHER', nume: 'ChaCha20', functii: ['encrypt', 'decrypt'] }; + if (/^PBEWITH(MD5|SHA1)AND(DES|TRIPLEDES|RC2|RC4)/.test(alg)) return { grup: 'CIPHER', nume: /TRIPLEDES/.test(alg) ? '3DES' : /RC2/.test(alg) ? 'RC2' : /RC4/.test(alg) ? 'RC4' : 'DES', nota: `${v} derives the key with a weak hash.` }; + if (/^ECIES/.test(alg)) return { grup: 'ECDH', nume: 'ECIES', primitiv: 'pke', functii: ['encrypt', 'decrypt'] }; + return { grup: 'UNKNOWN', motiv: `Cipher transformation "${v}" is not in this tool's catalog.` }; +} + +// nume post-cuantice in JCA sau Bouncy Castle +export function pqNume(v) { + const t = String(v).trim(); + let m; + if ((m = /^ML-?KEM(?:-(512|768|1024))?$/i.exec(t))) return { grup: 'MLKEM', param: m[1] }; + if ((m = /^ML-?DSA(?:-(44|65|87))?$/i.exec(t))) return { grup: 'MLDSA', param: m[1] }; + if ((m = /^SLH-?DSA(?:-(SHA2|SHAKE)-(128|192|256)([sfSF]))?$/i.exec(t))) return { grup: 'SLHDSA', param: m[1] ? `${m[1].toUpperCase()}-${m[2]}${m[3].toLowerCase()}` : undefined }; + if ((m = /^(?:Falcon|FN-DSA)(?:-(512|1024))?$/i.exec(t))) return { grup: 'FALCON', param: m[1] }; + if (/^(Kyber\d*|Dilithium\d*|SPHINCSPlus|SPHINCS\+)$/i.test(t)) return { grup: 'PREPQ', nume: t, primitiv: /kyber/i.test(t) ? 'kem' : 'signature' }; + if (/^(XMSS|XMSSMT|XMSS\^MT|LMS|HSS)$/i.test(t)) return { grup: 'HASHSIG', nume: t.toUpperCase() }; + if (/^(Rainbow|SIKE)$/i.test(t)) return { grup: 'BROKENPQ', nume: t, primitiv: /rainbow/i.test(t) ? 'signature' : 'kem' }; + if (/^HQC(-\d+)?$/i.test(t)) return { grup: 'HQC', nume: t.toUpperCase() }; + return null; +} + +const BC_CLASA = [ + [/^(ECDSASigner|ECNRSigner|DSTU4145Signer)$/, { grup: 'ECDSA' }], + [/^(DSASigner)$/, { grup: 'DSA' }], + [/^(RSADigestSigner|PSSSigner|RSAEngine|RSABlindedEngine|OAEPEncoding|PKCS1Encoding|RSAKeyPairGenerator|ISO9796d2Signer)$/, { grup: 'RSA' }], + [/^(ECKeyPairGenerator|ECNamedCurveTable|SECNamedCurves|ECDomainParameters)$/, { grup: 'EC' }], + [/^(ECDHBasicAgreement|ECDHCBasicAgreement|ECDHUnifiedAgreement)$/, { grup: 'ECDH' }], + [/^(DHBasicAgreement|DHAgreement|DHKeyPairGenerator)$/, { grup: 'DH' }], + [/^(Ed25519Signer|Ed25519ctxSigner|Ed25519phSigner|Ed25519KeyPairGenerator)$/, { grup: 'EDDSA', nume: 'Ed25519' }], + [/^(Ed448Signer|Ed448KeyPairGenerator)$/, { grup: 'EDDSA', nume: 'Ed448' }], + [/^(X25519Agreement|X25519KeyPairGenerator)$/, { grup: 'XDH', nume: 'X25519' }], + [/^(X448Agreement|X448KeyPairGenerator)$/, { grup: 'XDH', nume: 'X448' }], + [/^SecP256K1Curve$/, { grup: 'SECP256K1' }], + [/^MD5Digest$/, { grup: 'HASH', hash: 'MD5' }], + [/^SHA1Digest$/, { grup: 'HASH', hash: 'SHA1' }], + [/^SHA256Digest$/, { grup: 'HASH', hash: 'SHA256' }], + [/^SHA512Digest$/, { grup: 'HASH', hash: 'SHA512' }], + [/^(DESEngine)$/, { grup: 'CIPHER', nume: 'DES' }], + [/^(DESedeEngine)$/, { grup: 'CIPHER', nume: '3DES' }], + [/^(RC4Engine)$/, { grup: 'CIPHER', nume: 'RC4' }], + [/^(BlowfishEngine)$/, { grup: 'CIPHER', nume: 'Blowfish' }], + [/^(AESEngine|AESFastEngine|AESLightEngine)$/, { grup: 'CIPHER', nume: 'AES' }], +]; + +function bcActiv(cale) { + const clasa = cale.split('.').pop(); + let m; + if ((m = /^(MLKEM|MLDSA|SLHDSA|Falcon|Kyber|Dilithium|SPHINCSPlus|XMSS|XMSSMT|LMS|HSS|Rainbow|SIKE|HQC|NTRU|NTRUPrime|BIKE|Frodo|CMCE|Picnic|Saber)/.exec(clasa))) { + const fam = m[1]; + const map = { MLKEM: 'ML-KEM', MLDSA: 'ML-DSA', SLHDSA: 'SLH-DSA', SPHINCSPlus: 'SPHINCSPlus', XMSSMT: 'XMSSMT' }; + const pq = pqNume(map[fam] || fam); + if (pq) return pq; + return { grup: 'UNKNOWN', motiv: `Bouncy Castle post-quantum class ${clasa}: scheme "${fam}" is not classified by this tool (not a finalized NIST standard).` }; + } + if (/\.pqc\./.test(cale)) { + if (/BouncyCastlePQCProvider$/.test(clasa)) return { grup: 'UNKNOWN', motiv: 'Bouncy Castle PQC provider registered; the scheme is chosen elsewhere (not visible in this import).' }; + return { grup: 'UNKNOWN', motiv: `Bouncy Castle post-quantum package class ${clasa}; scheme not recognized by this tool.` }; + } + for (const [re, act] of BC_CLASA) if (re.test(clasa)) return act; + return null; +} + +// Variabila din `v = ` pe acelasi rand, citita INAPOI de la pozitie (ce dadea /([A-Za-z_$][\w$]*)\s*=\s*$/ pe textul randului +// de dinainte). Forma veche taia la fiecare apel tot randul pana la pozitie, deci un rand lung cu multe apeluri costa patratic +// (2026-09-29, revizuirea adversariala); cititul inapoi se opreste la primul caracter care nu poate face parte din tipar. +export function variabilaAtribuita(c, pos) { + const sp = (ch) => ch !== '\n' && /\s/.test(ch); + let j = pos - 1; + while (j >= 0 && sp(c[j])) j--; + if (j < 0 || c[j] !== '=') return null; + j--; + while (j >= 0 && sp(c[j])) j--; + const sf = j + 1; + while (j >= 0 && /[\w$]/.test(c[j])) j--; + let st = j + 1; + while (st < sf && /[0-9]/.test(c[st])) st++; + return st < sf ? c.slice(st, sf) : null; +} + +export function detecteazaJava(ctx) { + const c = ctx.code; + const consumate = new Set(); + for (const m of ctx.potriviri(/(? TLSV[x[1]]).filter(Boolean); + for (const x of vers) { + const ver = TLSV[x[1]]; + if (!ver) continue; + const rol = ver === '1.2' && lista.includes('1.3') ? 'min' : 'only'; + ctx.adauga(a.start + x.index, 'setEnabledProtocols', 'config', { grup: 'TLS', param: ver, rol }, { bucata: `"${x[1]}"` }); + } + } + // proprietati de sistem ale JSSE + for (const m of ctx.potriviri(/(?= 0) s = s.slice(k + 'node_modules/'.length); + if (/^(@[\w.-]+\/)?[\w.-]+\/.+\.(m?js|cjs)$/.test(s) && k >= 0) s = s.replace(/\.(m?js|cjs)$/, ''); + else if (/^@noble\//.test(s)) s = s.replace(/\.(m?js|cjs)$/, ''); + return s; +} + +function descrieImport(spec0, pos, clauza, implicitDinRequire = null, proprietate = null) { + const spec = normalizeazaSpec(spec0); + const importate = []; + const locale = []; + let implicit = implicitDinRequire; + const acolade = /\{([^}]*)\}/.exec(clauza || ''); + if (acolade) { + for (const bucata of acolade[1].split(',')) { + const t = bucata.trim().replace(/^type\s+/, ''); + if (!t) continue; + const m = /^([\w$]+)(?:\s*(?:as|:)\s*([\w$]+))?$/.exec(t); + if (m) { importate.push(m[1]); locale.push(m[2] || m[1]); } + } + } + const rest = (clauza || '').replace(/\{[^}]*\}/, '').replace(/^type\s+/, ''); + const ns = /\*\s*as\s+([\w$]+)/.exec(rest); + if (ns) implicit = ns[1]; + const def = /^\s*([\w$]+)\s*(?:,|$)/.exec(rest); + if (!implicit && def && def[1] !== 'type') implicit = def[1]; + if (proprietate) { importate.push(proprietate); if (implicit) locale.push(implicit); } + return { spec, pos, importate, locale, implicit }; +} + +export function detecteazaJs(ctx) { + const c = ctx.code; + const imp = importuriJs(ctx); + const importa = (re) => imp.filter((i) => re.test(i.spec)); + const cryptoImp = importa(/^(node:)?crypto$/); + const aliasCrypto = new Set(['crypto']); + for (const i of cryptoImp) { if (i.implicit) aliasCrypto.add(i.implicit); } + const numeImportate = new Set(cryptoImp.flatMap((i) => i.locale)); + const nodeCrypto = cryptoImp.length > 0 || ctx.potriviri(/(? 0; + + const apeluri = (nume) => ctx.potriviri(new RegExp(`(? m[1] !== 'subtle' && (m[1] ? aliasCrypto.has(m[1]) || nodeCrypto : (numeImportate.has(m[2]) || nodeCrypto))); + + const argsDe = (m) => { + const a = argumente(ctx, m.index + m[0].length); + return imparteArgumente(ctx, a.start, a.end); + }; + const cuValoare = (m, arg, api, fn) => { + const v = valoareArgument(ctx, arg); + if (v.fel === 'literal' || v.fel === 'rezolvat') return fn(v.valoare, v.fel === 'rezolvat' ? { rezolvatDin: v.din } : {}); + if (v.fel === 'necunoscut') ctx.adauga(m.index, api, 'call', necunoscut(v.expresie, api), { bucata: m[0] + v.expresie }); + return null; + }; + + if (nodeCrypto) { + for (const m of apeluri('createHash|createHmac')) { + const api = m[2]; + const p = argsDe(m); + cuValoare(m, p[0], api, (val, ex) => { + const h = hashCanonic(val) || val; + if (api === 'createHash') ctx.adauga(m.index, api, 'call', { grup: 'HASH', hash: h, functii: ['digest'] }, { ...ex, bucata: `${m[0]}'${val}'` }); + else ctx.adauga(m.index, api, 'call', { grup: 'MAC', hash: h, functii: ['tag'] }, { ...ex, bucata: `${m[0]}'${val}'` }); + }); + } + for (const m of apeluri('createCipheriv|createDecipheriv|createCipher|createDecipher')) { + const api = m[2]; + const p = argsDe(m); + cuValoare(m, p[0], api, (val, ex) => { + const cf = cifruOpenssl(val) || { grup: 'CIPHER', nume: val }; + ctx.adauga(m.index, api, 'call', { ...cf, functii: [/Decipher/.test(api) ? 'decrypt' : 'encrypt'] }, { ...ex, bucata: `${m[0]}'${val}'` }); + }); + } + for (const m of apeluri('createSign|createVerify')) { + const api = m[2]; + const p = argsDe(m); + cuValoare(m, p[0], api, (val, ex) => { + const f = [api === 'createSign' ? 'sign' : 'verify']; + const h = hashCanonic(val.replace(/^(RSA-|ecdsa-with-|DSA-|RSA-PSS-)/i, '').replace(/with(RSA|DSA)Encryption$/i, '').replace(/WithRSAEncryption$/i, '')); + let a; + if (/rsa/i.test(val)) a = { grup: 'RSA', primitiv: 'signature', hash: h }; + else if (/ecdsa/i.test(val)) a = { grup: 'ECDSA', hash: h }; + else if (/dsa/i.test(val)) a = { grup: 'DSA', hash: h }; + else a = { grup: 'CLASSIC-SIG', hash: h, motiv: `${api} with digest ${val} (Sign/Verify objects take RSA, RSA-PSS, DSA or EC keys; the key type comes from the key object)` }; + ctx.adauga(m.index, api, 'call', { ...a, functii: f }, { ...ex, bucata: `${m[0]}'${val}'` }); + }); + } + for (const m of apeluri('createECDH')) { + const p = argsDe(m); + cuValoare(m, p[0], 'createECDH', (val, ex) => ctx.adauga(m.index, 'createECDH', 'call', { grup: 'ECDH', curba: val, functii: ['keygen'] }, { ...ex, bucata: `${m[0]}'${val}'` })); + } + for (const m of apeluri('getDiffieHellman|createDiffieHellmanGroup')) { + const p = argsDe(m); + cuValoare(m, p[0], m[2], (val, ex) => ctx.adauga(m.index, m[2], 'call', { grup: 'DH', param: MODP[val] ? String(MODP[val]) : val, functii: ['keygen'] }, { ...ex, bucata: `${m[0]}'${val}'` })); + } + for (const m of apeluri('createDiffieHellman')) { + const p = argsDe(m); + const bits = p[0] && /^\d+$/.test(p[0].text) ? p[0].text : undefined; + ctx.adauga(m.index, 'createDiffieHellman', 'call', { grup: 'DH', param: bits, functii: ['keygen'] }, { bucata: m[0] + (bits || '') }); + } + for (const m of apeluri('generateKeySync|generateKey')) { + const p = argsDe(m); + const v = valoareArgument(ctx, p[0]); + if ((v.fel === 'literal' || v.fel === 'rezolvat') && v.valoare.toLowerCase() === 'aes') { + const len = p[1] && /length\s*:\s*(\d+)/.exec(p[1].text); + ctx.adauga(m.index, m[2], 'call', { grup: 'CIPHER', nume: 'AES', param: len ? len[1] : undefined, functii: ['keygen'] }, { bucata: `${m[0]}'aes'` }); + } + } + for (const m of apeluri('publicEncrypt|privateDecrypt|privateEncrypt|publicDecrypt')) { + const api = m[2]; + const a = argumente(ctx, m.index + m[0].length); + const t = a.text; + const enc = api === 'publicEncrypt' || api === 'privateDecrypt'; + let padding = enc ? 'oaep' : 'pkcs1v15'; + if (/RSA_PKCS1_OAEP_PADDING|oaepHash/.test(t)) padding = 'oaep'; + else if (/RSA_PKCS1_PADDING/.test(t)) padding = 'pkcs1v15'; + else if (/RSA_NO_PADDING/.test(t)) padding = 'raw'; + ctx.adauga(m.index, api, 'call', { grup: 'RSA', primitiv: enc ? 'pke' : 'signature', padding, functii: [api === 'publicEncrypt' || api === 'privateEncrypt' ? 'encrypt' : 'decrypt'] }, { bucata: m[0] }); + } + for (const m of apeluri('pbkdf2Sync|pbkdf2')) { + const p = argsDe(m); + cuValoare(m, p[4], m[2], (val, ex) => { + const h = hashCanonic(val) || val; + ctx.adauga(m.index, m[2], 'call', { grup: 'KDF', nume: `PBKDF2-HMAC-${val.toUpperCase()}`, hash: h, functii: ['keyderive'] }, { ...ex, bucata: m[0] }); + }); + } + for (const m of apeluri('hkdfSync|hkdf')) { + const p = argsDe(m); + cuValoare(m, p[0], m[2], (val, ex) => ctx.adauga(m.index, m[2], 'call', { grup: 'KDF', nume: `HKDF-${val.toUpperCase()}`, hash: hashCanonic(val) || val, functii: ['keyderive'] }, { ...ex, bucata: m[0] })); + } + // chei si certificate incarcate la rulare: algoritmul vine din material, nu din sursa + for (const m of [...apeluri('createPrivateKey|createPublicKey'), ...ctx.potriviri(/(? !x[1] || aliasCrypto.has(x[1]))]) { + const api = m[2]; + ctx.adauga(m.index, api, 'call', { grup: 'UNKNOWN', motiv: `${api}: key material loaded at run time; its algorithm (RSA, EC, Ed25519, ML-DSA, ...) comes from the key or certificate, which is not in the scanned source. This is a place where keys enter the program: check which algorithm is deployed there.`, functii: ['other'] }, { bucata: m[0] }); + } + // crypto.sign / crypto.verify (si importurile numite sign/verify) + for (const m of ctx.potriviri(/(? ctx.adauga(m.index, api, 'call', { grup: 'CLASSIC-SIG', hash: hashCanonic(val) || val, motiv: `${api} with digest ${val} (a digest name is used with RSA, RSA-PSS, DSA and EC keys; EdDSA and ML-DSA keys take null)`, functii: [m[2]] }, { ...ex, bucata: `${m[0]}'${val}'` })); + } + } + + // generateKeyPair: node:crypto sau jose (acolo primul argument e un algoritm JWS) + const jwtLib = importa(/^(jsonwebtoken|jose|jwt-simple|express-jwt|@fastify\/jwt|passport-jwt|koa-jwt|fast-jwt|jws)$/); + for (const m of ctx.potriviri(/(? { + if (JWS_RE.test(val) && jwtLib.length) return; // se raporteaza de regula JWT + const t = val.toLowerCase(); + const ml = /modulusLength\s*:\s*(\d+)/.exec(opt); + const nc = /namedCurve\s*:\s*(['"])([^'"]+)\1/.exec(opt); + let act; + if (t === 'rsa') act = { grup: 'RSA', param: ml && ml[1] }; + else if (t === 'rsa-pss') act = { grup: 'RSA', param: ml && ml[1], primitiv: 'signature', padding: 'other' }; + else if (t === 'dsa') act = { grup: 'DSA', param: ml && ml[1] }; + else if (t === 'ec') act = { grup: 'EC', curba: nc && nc[2] }; + else if (t === 'ed25519' || t === 'ed448') act = { grup: 'EDDSA', nume: t === 'ed25519' ? 'Ed25519' : 'Ed448' }; + else if (t === 'x25519' || t === 'x448') act = { grup: 'XDH', nume: t === 'x25519' ? 'X25519' : 'X448' }; + else if (t === 'dh') { + const pl = /primeLength\s*:\s*(\d+)/.exec(opt); + const gr = /group\s*:\s*(['"])(modp\d+)\1/.exec(opt); + act = { grup: 'DH', param: pl ? pl[1] : gr ? String(MODP[gr[2]] || gr[2]) : undefined }; + } else if (/^ml-dsa-(44|65|87)$/.test(t)) act = { grup: 'MLDSA', param: t.slice(7) }; + else if (/^ml-kem-(512|768|1024)$/.test(t)) act = { grup: 'MLKEM', param: t.slice(7) }; + else if (/^slh-dsa-(sha2|shake)-(128|192|256)[sf]$/.test(t)) act = { grup: 'SLHDSA', param: t.slice(8).toUpperCase().replace(/([SF])$/, (x) => x.toLowerCase()) }; + else act = { grup: 'UNKNOWN', motiv: `Key type "${val}" is not in this tool's catalog.` }; + ctx.adauga(m.index, m[2], 'call', { ...act, functii: ['keygen'] }, { ...ex, bucata: `${m[0]}'${val}'` }); + }); + } + + detecteazaTlsJs(ctx); + detecteazaWebCrypto(ctx); + detecteazaBiblioteci(ctx, imp, importa, jwtLib); +} + +function detecteazaTlsJs(ctx) { + for (const m of ctx.potriviri(/(? 80 ? t.slice(0, 77) + '...' : t; +} + +// valoarea unei expresii: identificator rezolvat, sau template ale carui ${ID} se rezolva toate; altfel null +function valoareExpresie(ctx, expr) { + const id = identificator(expr); + if (id) { + const r = rezolva(ctx, id); + return r ? { valoare: r.valoare, din: `${id} (line ${r.line})` } : null; + } + const t = /^`((?:[^`$\\]|\$\{\s*[A-Za-z_$][\w$]*\s*\})*)`$/.exec(expr); + if (!t) return null; + const din = []; + let ok = true; + const val = t[1].replace(/\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g, (_, n) => { + const r = rezolva(ctx, n); + if (!r) { ok = false; return ''; } + din.push(`${n} (line ${r.line})`); + return r.valoare; + }); + return ok ? { valoare: val, din: din.join(', ') } : null; +} + +const WEBCRYPTO_ALG = /^(RSA-OAEP|RSASSA-PKCS1-v1_5|RSA-PSS|ECDSA|ECDH|Ed25519|Ed448|X25519|X448|AES-GCM|AES-CBC|AES-CTR|AES-KW|HMAC|HKDF|PBKDF2|ML-KEM-(?:512|768|1024)|ML-DSA-(?:44|65|87)|ChaCha20-Poly1305)$/i; + +function activWebCrypto(nume, fereastra) { + const n = nume.toUpperCase(); + const ml = /modulusLength\s*:\s*(\d+)/.exec(fereastra); + const nc = /namedCurve\s*:\s*(['"])([^'"]+)\1/.exec(fereastra); + const len = /(? lista[0]; + + // Ethereum: conturile si semnaturile sunt secp256k1 ECDSA + const eth = importa(ETH); + if (eth.length) { + const i = primul(eth); + ctx.adauga(i.pos, `import ${i.spec}`, 'import', { grup: 'SECP256K1', nota: `Imported library: ${i.spec}, whose accounts and transaction signatures are secp256k1 ECDSA; the import alone does not show that this file signs.` }, { suprimaDe: ['SECP256K1'], bucata: `import ${i.spec}` }); + const re1 = /(? (m[1] ? re.test(m[1]) : m[2].split(',').some((x) => re.test(x.trim())))); +} + +export function detecteazaPy(ctx) { + const argsDe = (m) => { const a = argumente(ctx, m.index + m[0].length); return { ...a, parti: imparteArgumente(ctx, a.start, a.end) }; }; + const cuValoare = (m, arg, api, fn) => { + const v = valoareArgument(ctx, arg); + if (v.fel === 'literal' || v.fel === 'rezolvat') return fn(v.valoare, v.fel === 'rezolvat' ? { rezolvatDin: v.din } : {}); + if (v.fel === 'necunoscut') ctx.adauga(m.index, api, 'call', necunoscut(v.expresie, api), { bucata: m[0] + v.expresie }); + return null; + }; + const numar = (a, cheie, poz) => { + const k = new RegExp(`\\b${cheie}\\s*=\\s*(\\d+)`).exec(a.text); + if (k) return k[1]; + if (poz !== undefined && a.parti[poz] && /^\d+$/.test(a.parti[poz].text)) return a.parti[poz].text; + return undefined; + }; + + // --- cryptography.hazmat --- + if (importaPy(ctx, /^cryptography\b/).length) { + for (const m of ctx.potriviri(/(? ctx.adauga(m.index, 'hashlib.new', 'call', { grup: 'HASH', hash: hashCanonic(val) || val, functii: ['digest'] }, { ...ex, bucata: `hashlib.new('${val}')` })); + } + for (const m of ctx.potriviri(/(? ctx.adauga(m.index, 'hashlib.pbkdf2_hmac', 'call', { grup: 'KDF', nume: `PBKDF2-HMAC-${val.toUpperCase()}`, hash: hashCanonic(val) || val, functii: ['keyderive'] }, { ...ex, bucata: m[0] })); + } + for (const m of ctx.potriviri(/(? ctx.adauga(m.index, `oqs.${m[1]}`, 'call', oqsActiv(val), { ...ex, bucata: `oqs.${m[1]}('${val}')` })); + } + + // --- ssl --- + for (const m of ctx.potriviri(/(? ctx.adauga(m.index, 'set_ecdh_curve', 'config', { grup: 'ECDH', curba: val, functii: ['keygen'] }, { ...ex, bucata: m[0] })); + } +} + +export function oqsActiv(val) { + const t = String(val); + let m; + if ((m = /^ML-KEM-(512|768|1024)$/i.exec(t))) return { grup: 'MLKEM', param: m[1] }; + if ((m = /^ML-DSA-(44|65|87)$/i.exec(t))) return { grup: 'MLDSA', param: m[1] }; + if ((m = /^SLH[-_]DSA[-_](SHA2|SHAKE)[-_](128|192|256)([sf])/i.exec(t))) return { grup: 'SLHDSA', param: `${m[1].toUpperCase()}-${m[2]}${m[3].toLowerCase()}` }; + if ((m = /^Falcon-(512|1024)$/i.exec(t))) return { grup: 'FALCON', param: m[1] }; + if (/^(Kyber\d+|Dilithium\d|SPHINCS\+?-.*)$/i.test(t)) return { grup: 'PREPQ', nume: t, primitiv: /kyber/i.test(t) ? 'kem' : 'signature' }; + if (/^HQC-\d+$/i.test(t)) return { grup: 'HQC', nume: t }; + return { grup: 'UNKNOWN', motiv: `Post-quantum mechanism "${t}" is not in this tool's catalog.` }; +} diff --git a/crypto-inventory/lib/lexer.mjs b/crypto-inventory/lib/lexer.mjs new file mode 100644 index 0000000..afed0e0 --- /dev/null +++ b/crypto-inventory/lib/lexer.mjs @@ -0,0 +1,230 @@ +// Lexer minimal pe limbaje: comentariile devin spatii (liniile si coloanele raman pe loc), +// sirurile raman in text, dar li se tin minte intervalele, ca un detector sa poata cere +// "numele API-ului e COD, nu text dintr-un sir". +// +// Nu e un parser: e cat trebuie ca un tipar sa nu se potriveasca in comentarii, in +// docstring-uri Python sau in textul unui sir. + +const KW_REGEX = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', + 'throw', 'case', 'do', 'else', 'yield', 'await']); +const PUNCT_REGEX = '(,=:[!&|?{};+-*%<>~^'; + +export function curata(text, limbaj) { + switch (limbaj) { + case 'javascript': return curataC(text, { template: true, regex: true }); + case 'java': return curataC(text, { textBlock: true }); + case 'go': return curataC(text, { raw: true }); + case 'python': return curataPython(text); + default: return { code: text, siruri: [] }; + } +} + +function golitor(out) { + return (a, b) => { + for (let k = a; k < b; k++) if (out[k] !== '\n' && out[k] !== '\r') out[k] = ' '; + }; +} + +function curataC(text, o) { + const n = text.length; + const out = text.split(''); + const goleste = golitor(out); + const siruri = []; + const stiva = []; + let acolade = 0; + let prevSig = ''; + let prevWord = ''; + let i = 0; + let esecRegex = null; // bit 1: din (pozitie, in afara clasei) nu se ajunge la sfarsitul literalului; bit 2: la fel, in clasa + + // scaneaza un segment de template literal pornind de la j; intoarce pozitia si daca s-a deschis ${ + const scanTpl = (inceput, j) => { + while (j < n) { + const ch = text[j]; + if (ch === '\\') { j += 2; continue; } + if (ch === '`') { siruri.push([inceput, j + 1]); return { pos: j + 1, deschis: false }; } + if (ch === '$' && text[j + 1] === '{') { siruri.push([inceput, j + 2]); return { pos: j + 2, deschis: true }; } + j++; + } + siruri.push([inceput, n]); + return { pos: n, deschis: false }; + }; + + while (i < n) { + const c = text[i]; + const d = text[i + 1]; + if (c === '/' && d === '/') { + let j = i; + while (j < n && text[j] !== '\n') j++; + goleste(i, j); i = j; continue; + } + if (c === '/' && d === '*') { + let j = text.indexOf('*/', i + 2); + j = j < 0 ? n : j + 2; + goleste(i, j); i = j; continue; + } + if (o.textBlock && c === '"' && text.startsWith('"""', i)) { + let j = text.indexOf('"""', i + 3); + j = j < 0 ? n : j + 3; + siruri.push([i, j]); i = j; prevSig = '"'; continue; + } + if (c === '"' || c === "'") { + let j = i + 1; + while (j < n && text[j] !== c && text[j] !== '\n') { if (text[j] === '\\') j++; j++; } + j = Math.min(n, j + 1); + siruri.push([i, j]); i = j; prevSig = c; continue; + } + if (c === '`' && o.raw) { + let j = text.indexOf('`', i + 1); + j = j < 0 ? n : j + 1; + siruri.push([i, j]); i = j; prevSig = '`'; continue; + } + if (c === '`' && o.template) { + const r = scanTpl(i, i + 1); + i = r.pos; + if (r.deschis) { stiva.push(acolade); acolade++; } + prevSig = '`'; continue; + } + if (c === '/' && o.regex) { + const permis = prevSig === '' || PUNCT_REGEX.includes(prevSig) || (prevSig === 'a' && KW_REGEX.has(prevWord)); + if (permis) { + // Cautarea sfarsitului unui literal regex e determinista din (pozitie, "in clasa"), deci o stare din care s-a ajuns o data + // la capatul randului fara sfarsit duce acolo si a doua oara. Starile acestea se tin minte (2026-09-29, revizuirea + // adversariala): fara ele un rand lung cu multe `/` nedeschise (de ex. `=/[` repetat) costa patratic, minute pe 1 MB. + let j = i + 1; + let inClasa = false; + let bun = false; + const vizitate = []; + while (j < n && text[j] !== '\n') { + if (esecRegex && esecRegex[j] & (inClasa ? 2 : 1)) break; + vizitate.push(inClasa ? -j - 1 : j); + const ch = text[j]; + if (ch === '\\') { j += 2; continue; } + if (ch === '[') inClasa = true; + else if (ch === ']') inClasa = false; + else if (ch === '/' && !inClasa) { bun = true; break; } + j++; + } + if (!bun) { + if (!esecRegex) esecRegex = new Uint8Array(n); + for (const v of vizitate) { if (v < 0) esecRegex[-v - 1] |= 2; else esecRegex[v] |= 1; } + } + if (bun) { + j++; + while (j < n && /[a-z]/i.test(text[j])) j++; + siruri.push([i, j]); i = j; prevSig = '/'; continue; + } + } + prevSig = '/'; i++; continue; + } + if (c === '{') { acolade++; prevSig = '{'; i++; continue; } + if (c === '}') { + acolade--; + if (stiva.length && acolade === stiva[stiva.length - 1]) { + stiva.pop(); + const r = scanTpl(i, i + 1); + i = r.pos; + if (r.deschis) { stiva.push(acolade); acolade++; } + prevSig = '`'; continue; + } + prevSig = '}'; i++; continue; + } + if (/[A-Za-z_$]/.test(c)) { + let j = i + 1; + while (j < n && /[\w$]/.test(text[j])) j++; + prevWord = text.slice(i, j); prevSig = 'a'; i = j; continue; + } + if (/[0-9]/.test(c)) { + let j = i + 1; + while (j < n && /[\w.]/.test(text[j])) j++; + prevWord = ''; prevSig = '0'; i = j; continue; + } + if (!/\s/.test(c)) prevSig = c; + i++; + } + return { code: out.join(''), siruri }; +} + +function curataPython(text) { + const n = text.length; + const out = text.split(''); + const goleste = golitor(out); + const siruri = []; + let i = 0; + // primul caracter care nu e spatiu pe randul curent (-1 = niciunul inca). Tinut din mers (2026-09-29, revizuirea adversariala): + // forma veche cauta inapoi inceputul randului la FIECARE sir triplu, deci un rand lung cu multe siruri triple costa patratic. + let primulPeRand = -1; + while (i < n) { + const c = text[i]; + if (c === '\n') { primulPeRand = -1; i++; continue; } + if (c === '#') { + let j = i; + while (j < n && text[j] !== '\n') j++; + goleste(i, j); i = j; continue; + } + if (c === '"' || c === "'") { + // prefixul (r, b, f, u, rb, ...) face parte din sir + let start = i; + while (start > 0 && /[rRbBuUfF]/.test(text[start - 1]) && i - start < 2) start--; + if (start < i && start > 0 && /[\w]/.test(text[start - 1])) start = i; + const triplu = text.startsWith(c.repeat(3), i); + let j; + // inainte de sir, pe randul lui, sunt numai spatii (prefixul r/b/f/u face parte din sir)? + const singurPeRand = primulPeRand < 0 || primulPeRand >= start; + let ultimaLinieNoua = -1; + if (triplu) { + j = i + 3; + while (j < n && !text.startsWith(c.repeat(3), j)) { + if (text[j] === '\\') { if (text[j + 1] === '\n') ultimaLinieNoua = j + 1; j++; } else if (text[j] === '\n') ultimaLinieNoua = j; + j++; + } + j = Math.min(n, j + 3); + } else { + j = i + 1; + while (j < n && text[j] !== c && text[j] !== '\n') { if (text[j] === '\\') { if (text[j + 1] === '\n') ultimaLinieNoua = j + 1; j++; } j++; } + if (j < n && text[j] === '\n') ultimaLinieNoua = j; // sir neterminat: intervalul lui se opreste DUPA acest rand nou + j = Math.min(n, j + 1); + } + // dupa sir: daca sirul a trecut peste un rand nou, randul curent incepe in el (coada lui nu e spatiu), afara de cazul in + // care sirul se termina chiar cu randul nou; altfel randul curent are acum cel putin sirul + if (ultimaLinieNoua >= 0) primulPeRand = ultimaLinieNoua === j - 1 ? -1 : ultimaLinieNoua + 1; + else if (primulPeRand < 0) primulPeRand = start; + // docstring: sirul triplu e singur pe randul lui, ca instructiune; se trateaza ca un comentariu + if (triplu && singurPeRand) { goleste(start, j); i = j; continue; } + siruri.push([start, j]); i = j; continue; + } + if (primulPeRand < 0 && c !== ' ' && c !== '\t') primulPeRand = i; + i++; + } + return { code: out.join(''), siruri }; +} + +// cautare binara: pozitia e inauntrul unui sir? +export function inSir(siruri, pos) { + let lo = 0; + let hi = siruri.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const [s, e] = siruri[mid]; + if (pos < s) hi = mid - 1; + else if (pos >= e) lo = mid + 1; + else return true; + } + return false; +} + +export function inceputuriDeRand(text) { + const r = [0]; + for (let k = 0; k < text.length; k++) if (text.charCodeAt(k) === 10) r.push(k + 1); + return r; +} + +export function randul(inceputuri, pos) { + let lo = 0; + let hi = inceputuri.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (inceputuri[mid] <= pos) lo = mid; else hi = mid - 1; + } + return { line: lo + 1, column: pos - inceputuri[lo] + 1 }; +} diff --git a/crypto-inventory/lib/manifeste.mjs b/crypto-inventory/lib/manifeste.mjs new file mode 100644 index 0000000..22ffedb --- /dev/null +++ b/crypto-inventory/lib/manifeste.mjs @@ -0,0 +1,134 @@ +// Bibliotecile criptografice DECLARATE in manifeste. Declararea nu e folosire: aceste intrari +// ies ca componente "library", nu ca gasiri clasificate. +import { inceputuriDeRand, randul } from './lexer.mjs'; + +// nume -> ce ofera (text scurt, in engleza, pentru utilizator) +const NPM = { + 'node-forge': 'RSA, AES, DES, MD5, SHA-1, SHA-2, X.509 (classical)', + elliptic: 'ECDSA/ECDH/EdDSA on classical curves including secp256k1', + secp256k1: 'secp256k1 ECDSA', 'tiny-secp256k1': 'secp256k1 ECDSA', '@noble/secp256k1': 'secp256k1 ECDSA', + ethers: 'secp256k1 ECDSA accounts and signatures', web3: 'secp256k1 ECDSA accounts and signatures', viem: 'secp256k1 ECDSA accounts and signatures', + 'ethereum-cryptography': 'secp256k1, keccak, AES, scrypt', + jsonwebtoken: 'JWS HS*/RS*/PS*/ES*', jose: 'JWS/JWE (classical algorithms)', 'jwt-simple': 'JWS HS*/RS*', + '@noble/curves': 'secp256k1, P-256/384/521, Ed25519/X25519, Ed448, BLS12-381 (classical)', + '@noble/post-quantum': 'ML-KEM, ML-DSA, SLH-DSA (post-quantum)', + '@noble/hashes': 'SHA-2, SHA-3, BLAKE, legacy MD5/SHA-1/RIPEMD-160', + '@noble/ed25519': 'Ed25519', + 'crypto-js': 'AES, DES, 3DES, RC4, MD5, SHA-1, SHA-2', tweetnacl: 'Ed25519, X25519, XSalsa20-Poly1305', + 'libsodium-wrappers': 'Ed25519, X25519, XChaCha20-Poly1305, Argon2', 'node-rsa': 'RSA', jsrsasign: 'RSA, ECDSA, X.509', + openpgp: 'OpenPGP: RSA, ECC, AES', bcrypt: 'bcrypt password hashing', bcryptjs: 'bcrypt password hashing', argon2: 'Argon2 password hashing', + sshpk: 'SSH keys: RSA, ECDSA, Ed25519', '@peculiar/x509': 'X.509 certificates', pkijs: 'X.509/CMS', +}; +const PYPI = { + cryptography: 'RSA, EC, DH, Ed25519/X25519, AES, hashes (pyca/cryptography)', + pycryptodome: 'RSA, DSA, ECC, AES, DES, hashes', pycryptodomex: 'RSA, DSA, ECC, AES, DES, hashes', + pycrypto: 'RSA, DSA, AES, DES (unmaintained since 2013)', ecdsa: 'ECDSA on classical curves', pyjwt: 'JWS HS*/RS*/ES*/PS*/EdDSA', + 'python-jose': 'JWS/JWE (classical algorithms)', jwcrypto: 'JWS/JWE (classical algorithms)', pynacl: 'Ed25519, X25519', + pyopenssl: 'TLS and X.509 via OpenSSL', paramiko: 'SSH: RSA, ECDSA, Ed25519, classical key exchange', 'liboqs-python': 'post-quantum KEMs and signatures (liboqs)', + oqs: 'post-quantum KEMs and signatures (liboqs)', web3: 'secp256k1 ECDSA accounts', 'eth-account': 'secp256k1 ECDSA accounts', 'eth-keys': 'secp256k1 ECDSA', + coincurve: 'secp256k1 ECDSA', bcrypt: 'bcrypt password hashing', 'argon2-cffi': 'Argon2 password hashing', passlib: 'password hashing', rsa: 'RSA (pure Python)', +}; +const MAVEN_GROUP = { + 'org.bouncycastle': 'Bouncy Castle: classical and post-quantum algorithms', + 'io.jsonwebtoken': 'JJWT: JWS HS*/RS*/ES*/PS*/EdDSA', 'com.nimbusds': 'Nimbus JOSE+JWT', 'com.auth0': 'java-jwt (if artifact is java-jwt)', + 'org.web3j': 'secp256k1 ECDSA accounts (web3j)', 'com.google.crypto.tink': 'Tink: AEAD, signatures, hybrid encryption', 'org.conscrypt': 'Conscrypt TLS provider', +}; +const GO = [ + [/^golang\.org\/x\/crypto$/, 'extended crypto: ssh, chacha20poly1305, curve25519, bcrypt, argon2'], + [/^github\.com\/cloudflare\/circl$/, 'CIRCL: ML-KEM, ML-DSA, SLH-DSA, hybrid KEMs, classical curves'], + [/^github\.com\/ethereum\/go-ethereum$/, 'secp256k1 ECDSA accounts and signatures'], + [/^github\.com\/golang-jwt\/jwt(\/v\d+)?$/, 'JWS HS*/RS*/ES*/PS*/EdDSA'], + [/^github\.com\/decred\/dcrd\/dcrec\/secp256k1(\/v\d+)?$/, 'secp256k1 ECDSA'], + [/^github\.com\/btcsuite\/btcd\/btcec(\/v\d+)?$/, 'secp256k1 ECDSA'], + [/^filippo\.io\/edwards25519$/, 'Edwards25519 group arithmetic'], + [/^github\.com\/open-quantum-safe\/liboqs-go$/, 'post-quantum KEMs and signatures (liboqs)'], +]; + +export const NUME_MANIFEST = /^(package\.json|requirements[\w.-]*\.txt|pyproject\.toml|pom\.xml|build\.gradle(\.kts)?|go\.mod)$/; + +function lib(ecosistem, nume, versiune, ofera, pos, inceputuri, fisier) { + const { line } = randul(inceputuri, pos); + const purl = ecosistem === 'npm' ? `pkg:npm/${nume.startsWith('@') ? '%40' + nume.slice(1) : nume}${versiune && /^[\w.-]+$/.test(versiune) ? '@' + versiune : ''}` + : ecosistem === 'pypi' ? `pkg:pypi/${nume}${versiune && /^[\w.-]+$/.test(versiune) ? '@' + versiune : ''}` + : ecosistem === 'maven' ? `pkg:maven/${nume.replace(':', '/')}${versiune && /^[\w.-]+$/.test(versiune) ? '@' + versiune : ''}` + : `pkg:golang/${nume}${versiune ? '@' + versiune : ''}`; + return { file: fisier, line, ecosystem: ecosistem, name: nume, version: versiune || undefined, provides: ofera, purl }; +} + +export function citesteManifest(text, numeFisier, rel) { + const inceputuri = inceputuriDeRand(text); + const r = []; + if (numeFisier === 'package.json') { + let j; + try { j = JSON.parse(text); } catch { return { biblioteci: [], eroare: 'package.json is not valid JSON' }; } + for (const sect of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + const d = j && j[sect]; + if (!d || typeof d !== 'object') continue; + const ps = text.indexOf(`"${sect}"`); + for (const [nume, ver] of Object.entries(d)) { + const ofera = NPM[nume] || (/^@ethersproject\//.test(nume) ? 'secp256k1 ECDSA (ethers v5 module)' : null); + if (!ofera) continue; + const pos = text.indexOf(`"${nume}"`, ps < 0 ? 0 : ps); + r.push(lib('npm', nume, String(ver).replace(/^[\^~>=<\s]+/, ''), ofera, pos < 0 ? 0 : pos, inceputuri, rel)); + } + } + } else if (/^requirements/.test(numeFisier)) { + let pos = 0; + for (const linie of text.split('\n')) { + const t = linie.replace(/#.*/, '').trim(); + const m = /^([A-Za-z0-9_.-]+)(?:\[[^\]]*\])?\s*(?:[=<>!~]=?\s*([\w.*+-]+))?/.exec(t); + if (m) { + const nume = m[1].toLowerCase().replace(/_/g, '-'); + if (PYPI[nume]) r.push(lib('pypi', nume, m[2], PYPI[nume], pos + linie.indexOf(m[1]), inceputuri, rel)); + } + pos += linie.length + 1; + } + } else if (numeFisier === 'pyproject.toml') { + let pos = 0; + let sectiune = ''; + for (const linie of text.split('\n')) { + const h = /^\s*\[([^\]]+)\]/.exec(linie); + if (h) sectiune = h[1]; + for (const m of linie.matchAll(/["']([A-Za-z0-9_.-]+)(?:\[[^\]]*\])?\s*(?:[=<>!~]=?\s*([\w.*+-]+))?[^"']*["']/g)) { + const nume = m[1].toLowerCase().replace(/_/g, '-'); + if (PYPI[nume]) r.push(lib('pypi', nume, m[2], PYPI[nume], pos + m.index + 1, inceputuri, rel)); + } + const p = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(?:["']([^"']*)["']|\{)/.exec(linie); + if (p && /dependencies/.test(sectiune)) { + const nume = p[1].toLowerCase().replace(/_/g, '-'); + if (PYPI[nume]) r.push(lib('pypi', nume, p[2] ? p[2].replace(/^[\^~>=<\s]+/, '') : undefined, PYPI[nume], pos + linie.indexOf(p[1]), inceputuri, rel)); + } + pos += linie.length + 1; + } + } else if (numeFisier === 'pom.xml') { + for (const m of text.matchAll(/([\s\S]*?)<\/dependency>/g)) { + const g = /\s*([^<\s]+)\s*<\/groupId>/.exec(m[1]); + const a = /\s*([^<\s]+)\s*<\/artifactId>/.exec(m[1]); + const v = /\s*([^<\s]+)\s*<\/version>/.exec(m[1]); + if (!g || !a) continue; + const ofera = MAVEN_GROUP[g[1]]; + if (!ofera) continue; + r.push(lib('maven', `${g[1]}:${a[1]}`, v && !/\$\{/.test(v[1]) ? v[1] : undefined, ofera, m.index + m[0].indexOf(a[0]), inceputuri, rel)); + } + } else if (/^build\.gradle/.test(numeFisier)) { + for (const m of text.matchAll(/["']([\w.-]+):([\w.-]+)(?::([\w.-]+))?["']/g)) { + const ofera = MAVEN_GROUP[m[1]]; + if (ofera) r.push(lib('maven', `${m[1]}:${m[2]}`, m[3], ofera, m.index + 1, inceputuri, rel)); + } + } else if (numeFisier === 'go.mod') { + let pos = 0; + let inRequire = false; + for (const linie of text.split('\n')) { + const t = linie.replace(/\/\/.*/, '').trim(); + if (/^require\s*\($/.test(t)) inRequire = true; + else if (inRequire && t === ')') inRequire = false; + const m = inRequire ? /^([\w.\-/]+)\s+(v[\w.\-+]+)/.exec(t) : /^require\s+([\w.\-/]+)\s+(v[\w.\-+]+)/.exec(t); + if (m) { + const intrare = GO.find(([re]) => re.test(m[1])); + if (intrare) r.push(lib('golang', m[1], m[2], intrare[1], pos + linie.indexOf(m[1]), inceputuri, rel)); + } + pos += linie.length + 1; + } + } + return { biblioteci: r }; +} diff --git a/crypto-inventory/lib/pem.mjs b/crypto-inventory/lib/pem.mjs new file mode 100644 index 0000000..e6f8922 --- /dev/null +++ b/crypto-inventory/lib/pem.mjs @@ -0,0 +1,103 @@ +// Material criptografic in text: blocuri PEM complete (antet, corp base64, subsol). +// Cheile private se raporteaza DOAR ca locatie si tip; valoarea nu se tipareste niciodata. +// Tipul cheii se afla parsand blocul in memorie cu node:crypto (date, nu cod executat). +import { createPublicKey, createPrivateKey, X509Certificate } from 'node:crypto'; + +// Cautarea blocurilor e LINIARA in marimea textului (2026-09-29, revizuirea adversariala): forma veche, un singur regex cu +// corp lenes si referinta inapoi, relua cautarea sfarsitului de la FIECARE antet BEGIN, deci un fisier de 1 MB cu antete fara +// sfarsit costa minute. Acum pentru fiecare antet se cere PRIMUL `-----END -----` de dupa el si ca intre ele sa nu fie niciun +// caracter din afara alfabetului corpului, adica exact ce potrivea regexul vechi; ambele pozitii se tin minte si se cauta numai +// inainte, deci fiecare caracter se citeste de un numar marginit de ori. +const SURSA_BEGIN = '-----BEGIN ((?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY|PUBLIC KEY|RSA PUBLIC KEY|CERTIFICATE)-----'; +const SURSA_AFARA_DIN_CORP = '[^A-Za-z0-9+/=\\s:,\\-\\r\\n]'; + +export function* blocuriPem(text) { + const reBegin = new RegExp(SURSA_BEGIN, 'g'); + const reRau = new RegExp(SURSA_AFARA_DIN_CORP, 'g'); + // o cautare tinuta minte e buna pentru pozitia `de` daca a pornit la sau inainte de `de` si nu a gasit ceva inainte de `de` + const valabil = (c, de) => c && c.la <= de && (c.poz < 0 || c.poz >= de); + const sfarsit = new Map(); // tip -> { la, poz }: primul END al tipului la pozitia >= la (poz -1 = niciunul) + let rau = null; // { la, poz }: primul caracter din afara alfabetului corpului, la pozitia >= la + const urmatorulSfarsit = (tip, de) => { + let c = sfarsit.get(tip); + if (!valabil(c, de)) { c = { la: de, poz: text.indexOf(`-----END ${tip}-----`, de) }; sfarsit.set(tip, c); } + return c.poz; + }; + const urmatorulRau = (de) => { + if (!valabil(rau, de)) { reRau.lastIndex = de; const x = reRau.exec(text); rau = { la: de, poz: x ? x.index : -1 }; } + return rau.poz; + }; + let m; + while ((m = reBegin.exec(text))) { + const tip = m[1]; + const corpDe = m.index + m[0].length; + const e = urmatorulSfarsit(tip, corpDe); + const r = e < 0 ? -1 : urmatorulRau(corpDe); + // fara potrivire: cautarea continua de la caracterul urmator, ca la regexul vechi (un antet poate incepe in liniutele celui de dinainte) + if (e < 0 || (r >= 0 && r < e)) { reBegin.lastIndex = m.index + 1; continue; } + const capat = e + `-----END ${tip}-----`.length; + yield { index: m.index, tip, corp: text.slice(corpDe, e), bloc: text.slice(m.index, capat) }; + reBegin.lastIndex = capat; + } +} + +function activCheie(ko) { + const t = ko.asymmetricKeyType; + const d = ko.asymmetricKeyDetails || {}; + if (t === 'rsa' || t === 'rsa-pss') return { grup: 'RSA', param: d.modulusLength ? String(d.modulusLength) : undefined }; + if (t === 'dsa') return { grup: 'DSA', param: d.modulusLength ? String(d.modulusLength) : undefined }; + if (t === 'dh') return { grup: 'DH' }; + if (t === 'ec') return { grup: 'EC', curba: d.namedCurve }; + if (t === 'ed25519' || t === 'ed448') return { grup: 'EDDSA', nume: t === 'ed25519' ? 'Ed25519' : 'Ed448' }; + if (t === 'x25519' || t === 'x448') return { grup: 'XDH', nume: t === 'x25519' ? 'X25519' : 'X448' }; + if (/^ml-dsa-(44|65|87)$/.test(t)) return { grup: 'MLDSA', param: t.slice(7) }; + if (/^ml-kem-(512|768|1024)$/.test(t)) return { grup: 'MLKEM', param: t.slice(7) }; + if (/^slh-dsa/.test(t)) return { grup: 'SLHDSA', param: t.slice(8) }; + return { grup: 'UNKNOWN', motiv: `Key type "${t}" is not in this tool's catalog.` }; +} + +export function detecteazaPem(text, ctx) { + for (const b of blocuriPem(text)) { + const m = [b.bloc, b.tip, b.corp]; + m.index = b.index; + const corp = m[2].replace(/[\s]/g, '').replace(/^Proc-Type:.*?DEK-Info:[^A-Za-z0-9+/]*/, ''); + if (corp.replace(/[^A-Za-z0-9+/=]/g, '').length < 64) continue; // un antet singur (de ex. intr-un parser) nu e material + const tip = m[1]; + let act; + let assetType; + if (tip === 'CERTIFICATE') { + assetType = 'certificate'; + try { + const x = new X509Certificate(m[0]); + act = { ...activCheie(x.publicKey), nota: 'Certificate public key.' }; + act.certificat = { + subjectName: x.subject.replace(/\n/g, ', '), + issuerName: x.issuer.replace(/\n/g, ', '), + notValidBefore: new Date(x.validFrom).toISOString(), + notValidAfter: new Date(x.validTo).toISOString(), + certificateFormat: 'X.509', + certificateExtension: 'pem', + fingerprintSha256: x.fingerprint256, + }; + } catch { + act = { grup: 'UNKNOWN', motiv: 'PEM certificate block that could not be parsed.', certificat: { certificateFormat: 'X.509', certificateExtension: 'pem' } }; + } + } else if (/PUBLIC KEY/.test(tip)) { + assetType = 'related-crypto-material'; + try { act = activCheie(createPublicKey(m[0])); } catch { act = tip === 'RSA PUBLIC KEY' ? { grup: 'RSA' } : { grup: 'UNKNOWN', motiv: 'PEM public key block that could not be parsed.' }; } + act.material = { type: 'public-key', format: 'PEM' }; + } else { + assetType = 'related-crypto-material'; + if (/ENCRYPTED/.test(tip) || /Proc-Type:\s*4,ENCRYPTED/.test(m[2])) act = { grup: 'UNKNOWN', motiv: 'Encrypted private key: algorithm not visible without the passphrase.' }; + else if (tip === 'OPENSSH PRIVATE KEY') act = { grup: 'UNKNOWN', motiv: 'OpenSSH private key: algorithm not parsed by this tool.' }; + else { + try { act = activCheie(createPrivateKey(m[0])); } catch { + act = tip.startsWith('RSA') ? { grup: 'RSA' } : tip.startsWith('EC') ? { grup: 'EC' } : tip.startsWith('DSA') ? { grup: 'DSA' } : { grup: 'UNKNOWN', motiv: 'PEM private key block that could not be parsed.' }; + } + } + act.material = { type: 'private-key', format: 'PEM' }; + act.nota = 'Private key material stored in the scanned tree: if it is real, treat it as exposed and rotate it.'; + } + ctx.adauga(m.index, `PEM ${tip}`, 'pem', { ...act, assetType }, { bucata: `-----BEGIN ${tip}-----` }); + } +} diff --git a/crypto-inventory/lib/rezumat.mjs b/crypto-inventory/lib/rezumat.mjs new file mode 100644 index 0000000..536b20c --- /dev/null +++ b/crypto-inventory/lib/rezumat.mjs @@ -0,0 +1,76 @@ +// Rezumatul text (engleza, pentru utilizator) si regula --fail-on. +import { CLS } from './catalog.mjs'; + +const ORDINE = [CLS.V, CLS.W, CLS.S, CLS.U]; + +export function numarPeClase(gasiri) { + const r = Object.fromEntries(ORDINE.map((c) => [c, 0])); + for (const g of gasiri) r[g.classification] = (r[g.classification] || 0) + 1; + return r; +} + +// --fail-on: lista separata prin virgula din vulnerable, weak, unknown, any +// "vulnerable" = orice gasire pe care un calculator cuantic o sparge (inclusiv cele weak-now care sunt si Shor) +export function verificaFailOn(gasiri, spec) { + if (!spec) return { esec: false, motive: [] }; + const cer = new Set(String(spec).split(',').map((x) => x.trim()).filter(Boolean)); + const necunoscute = [...cer].filter((x) => !['vulnerable', 'weak', 'unknown', 'any'].includes(x)); + if (necunoscute.length) throw new Error(`--fail-on: unknown value(s): ${necunoscute.join(', ')} (use vulnerable, weak, unknown or any)`); + const motive = []; + const vuln = gasiri.filter((g) => g.classification === CLS.V || (g.classification === CLS.W && g.quantumVulnerable)); + const slabe = gasiri.filter((g) => g.classification === CLS.W); + const nec = gasiri.filter((g) => g.classification === CLS.U); + if ((cer.has('vulnerable') || cer.has('any')) && vuln.length) motive.push(`${vuln.length} quantum-vulnerable finding(s)`); + if ((cer.has('weak') || cer.has('any')) && slabe.length) motive.push(`${slabe.length} weak-now finding(s)`); + if (cer.has('unknown') && nec.length) motive.push(`${nec.length} unknown finding(s)`); + return { esec: motive.length > 0, motive }; +} + +export function rezumatText(rez) { + const s = rez.stats; + const g = rez.findings; + const L = []; + L.push(`Aere crypto inventory ${rez.tool.version}: ${rez.rootName}`); + const limbaje = Object.entries(s.codeFiles).sort().map(([k, v]) => `${k} ${v}`).join(', ') || 'none'; + L.push(`Files: ${s.filesSeen} seen | ${s.codeFilesTotal} analyzed as code (${limbaje}) | ${s.textFilesPemOnly} other text files checked for PEM key/certificate blocks only`); + const nr = s.notRead; + L.push(`Not read: ${nr.binary} binary, ${nr.tooLarge} too large (> ${rez.options.maxFileBytes} bytes), ${nr.fileLimit} over the file limit (${rez.options.maxFiles}), ${nr.unreadable} unreadable; ${s.symlinksNotFollowed} symlinks not followed`); + for (const k of ['binary', 'tooLarge', 'fileLimit', 'unreadable']) { + if (s.notReadExamples[k].length) L.push(` e.g. ${k}: ${s.notReadExamples[k].join(', ')}${nr[k] > s.notReadExamples[k].length ? ', ...' : ''}`); + } + if (s.resolutionLimitFiles) L.push(`Variable resolution limit (${rez.options.maxResolvedVariablesPerFile} distinct variables per file) reached in ${s.resolutionLimitFiles} file(s); further variables there are reported as unknown: ${s.resolutionLimitExamples.join(', ')}${s.resolutionLimitFiles > s.resolutionLimitExamples.length ? ', ...' : ''}`); + const ex = Object.entries(s.dirsExcluded).sort().map(([n, c]) => `${n} (${c})`).join(', '); + L.push(`Directories not descended: ${ex || 'none'}${s.dirsUnreadable ? `; ${s.dirsUnreadable} unreadable` : ''}`); + const pk = {}; + for (const x of g) pk[x.evidenceKind] = (pk[x.evidenceKind] || 0) + 1; + L.push(`Findings: ${g.length} (${Object.entries(pk).sort().map(([k, v]) => `${k} ${v}`).join(', ') || 'none'})`); + const cl = numarPeClase(g); + const wq = g.filter((x) => x.classification === CLS.W && x.quantumVulnerable).length; + for (const c of ORDINE) L.push(` ${c.padEnd(19)} ${String(cl[c]).padStart(5)}${c === CLS.W && wq ? ` (${wq} also quantum-vulnerable)` : ''}`); + const pl = {}; + for (const x of g) pl[x.language] = (pl[x.language] || 0) + 1; + L.push(`By language: ${Object.entries(pl).sort().map(([k, v]) => `${k} ${v}`).join(', ') || 'none'}`); + const frecv = {}; + for (const x of g) { const k = `${x.name} [${x.classification}]`; frecv[k] = (frecv[k] || 0) + 1; } + const top = Object.entries(frecv).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, 10); + if (top.length) { + L.push('Most frequent:'); + top.forEach(([k, v], i) => L.push(` ${String(i + 1).padStart(2)}. ${k} x${v}`)); + } + const vf = new Map(); + for (const x of g) { + if (!(x.classification === CLS.V || (x.classification === CLS.W && x.quantumVulnerable))) continue; + if (!vf.has(x.file)) vf.set(x.file, []); + vf.get(x.file).push(x); + } + L.push(`Files with quantum-vulnerable findings: ${vf.size}`); + for (const [f, lista] of [...vf].sort()) { + L.push(` ${f} (${lista.length}: ${[...new Set(lista.map((x) => x.name))].join(', ')})`); + } + L.push(`Declared crypto libraries (manifests; declaration is not use): ${rez.libraries.length}`); + for (const b of rez.libraries) L.push(` ${b.file}:${b.line} ${b.name}${b.version ? ' ' + b.version : ''} (${b.provides})`); + if (s.manifestErrors.length) L.push(`Manifest errors: ${s.manifestErrors.join('; ')}`); + L.push('Scope: static pattern analysis of source text. Not covered: compiled binaries, transitive dependencies, cryptography chosen at run time.'); + // numele de fisiere si textele vin din arborele scanat: caracterele de control (secvente ANSI) nu ajung in terminal + return L.map((l) => l.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?')).join('\n'); +} diff --git a/crypto-inventory/lib/scan.mjs b/crypto-inventory/lib/scan.mjs new file mode 100644 index 0000000..fbf7643 --- /dev/null +++ b/crypto-inventory/lib/scan.mjs @@ -0,0 +1,141 @@ +// Parcurgerea dosarului si orchestrarea detectorilor. Nimic nu se sare in tacere: fiecare +// fisier vazut ajunge intr-o singura categorie numarata, si suma se verifica la sfarsit. +import { readdirSync, statSync, readFileSync, lstatSync } from 'node:fs'; +import { join, relative, sep, basename, resolve, extname } from 'node:path'; +import { facContext, aplicaSuprimarea, REZOLVARI_PE_FISIER } from './context.mjs'; +import { detecteazaJs } from './detect-js.mjs'; +import { detecteazaPy } from './detect-py.mjs'; +import { detecteazaJava } from './detect-java.mjs'; +import { detecteazaGo } from './detect-go.mjs'; +import { detecteazaPem } from './pem.mjs'; +import { citesteManifest, NUME_MANIFEST } from './manifeste.mjs'; + +export const VERSIUNE = '0.2.0'; +export const DOSARE_EXCLUSE_IMPLICIT = ['.git', '.hg', '.svn', 'node_modules', '__pycache__', '.venv', 'venv', '.tox', '.gradle', '.idea', '.mypy_cache', '.pytest_cache']; + +const LIMBAJ = { + '.js': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript', '.jsx': 'javascript', + '.ts': 'javascript', '.tsx': 'javascript', '.mts': 'javascript', '.cts': 'javascript', + '.py': 'python', '.pyw': 'python', + '.java': 'java', + '.go': 'go', +}; +const DETECTOR = { javascript: detecteazaJs, python: detecteazaPy, java: detecteazaJava, go: detecteazaGo }; +const EXEMPLE = 5; + +export function limbajul(cale) { + return LIMBAJ[extname(cale).toLowerCase()] || null; +} + +// binar = contine un octet NUL in primii 8192 octeti +export function eBinar(buf) { + const n = Math.min(buf.length, 8192); + for (let i = 0; i < n; i++) if (buf[i] === 0) return true; + return false; +} + +export function scaneaza(radacina, optiuni = {}) { + const o = { + maxFileBytes: optiuni.maxFileBytes ?? 1048576, + maxFiles: optiuni.maxFiles ?? 50000, + excludeDirs: new Set([...(optiuni.noDefaultExcludes ? [] : DOSARE_EXCLUSE_IMPLICIT), ...(optiuni.excludeDirs || [])]), + }; + const root = resolve(radacina); + const st = statSync(root); + if (!st.isDirectory()) throw new Error(`not a directory: ${radacina}`); + const inceput = new Date(); + const stats = { + filesSeen: 0, + codeFiles: {}, + textFilesPemOnly: 0, + manifests: 0, + manifestErrors: [], + notRead: { binary: 0, tooLarge: 0, fileLimit: 0, unreadable: 0 }, + notReadExamples: { binary: [], tooLarge: [], fileLimit: [], unreadable: [] }, + symlinksNotFollowed: 0, + resolutionLimitFiles: 0, + resolutionLimitExamples: [], + dirsExcluded: {}, + dirsUnreadable: 0, + }; + const gasiri = []; + const biblioteci = []; + let cititeSauIncercate = 0; + const noteaza = (motiv, rel, extra) => { + stats.notRead[motiv]++; + if (stats.notReadExamples[motiv].length < EXEMPLE) stats.notReadExamples[motiv].push(extra ? `${rel} (${extra})` : rel); + }; + + const stiva = [root]; + while (stiva.length) { + const dir = stiva.pop(); + let intrari; + try { intrari = readdirSync(dir, { withFileTypes: true }); } catch { stats.dirsUnreadable++; continue; } + intrari.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + const subdosare = []; + for (const e of intrari) { + const abs = join(dir, e.name); + if (e.isSymbolicLink()) { stats.symlinksNotFollowed++; continue; } + if (e.isDirectory()) { + if (o.excludeDirs.has(e.name)) { stats.dirsExcluded[e.name] = (stats.dirsExcluded[e.name] || 0) + 1; continue; } + subdosare.push(abs); + continue; + } + if (!e.isFile()) continue; + stats.filesSeen++; + const rel = relative(root, abs).split(sep).join('/'); + if (cititeSauIncercate >= o.maxFiles) { noteaza('fileLimit', rel); continue; } + cititeSauIncercate++; + let marime; + try { marime = lstatSync(abs).size; } catch (err) { noteaza('unreadable', rel, err.code); continue; } + if (marime > o.maxFileBytes) { noteaza('tooLarge', rel, `${marime} bytes`); continue; } + let buf; + try { buf = readFileSync(abs); } catch (err) { noteaza('unreadable', rel, err.code); continue; } + if (eBinar(buf)) { noteaza('binary', rel); continue; } + const text = buf.toString('utf8'); + const limbaj = limbajul(abs); + const ctx = facContext(text, limbaj || 'text', rel); + if (limbaj) { + stats.codeFiles[limbaj] = (stats.codeFiles[limbaj] || 0) + 1; + DETECTOR[limbaj](ctx); + if (ctx.rezolvariPestePlafon) { + stats.resolutionLimitFiles++; + if (stats.resolutionLimitExamples.length < EXEMPLE) stats.resolutionLimitExamples.push(rel); + } + } else { + stats.textFilesPemOnly++; + } + detecteazaPem(text, ctx); + gasiri.push(...aplicaSuprimarea(ctx.gasiri)); + if (NUME_MANIFEST.test(basename(abs))) { + stats.manifests++; + const r = citesteManifest(text, basename(abs), rel); + if (r.eroare) stats.manifestErrors.push(`${rel}: ${r.eroare}`); + biblioteci.push(...r.biblioteci); + } + } + for (let i = subdosare.length - 1; i >= 0; i--) stiva.push(subdosare[i]); + } + + // garda: fiecare fisier vazut e intr-o singura categorie + const cod = Object.values(stats.codeFiles).reduce((a, b) => a + b, 0); + const nr = stats.notRead; + const suma = cod + stats.textFilesPemOnly + nr.binary + nr.tooLarge + nr.fileLimit + nr.unreadable; + if (suma !== stats.filesSeen) throw new Error(`internal accounting error: ${stats.filesSeen} files seen, ${suma} accounted for`); + stats.codeFilesTotal = cod; + + gasiri.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line || a.column - b.column)); + biblioteci.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line)); + return { + tool: { name: 'aere-crypto-inventory', version: VERSIUNE }, + root, + rootName: basename(root), + options: { maxFileBytes: o.maxFileBytes, maxFiles: o.maxFiles, excludeDirs: [...o.excludeDirs].sort(), maxResolvedVariablesPerFile: REZOLVARI_PE_FISIER }, + startedAt: inceput.toISOString(), + finishedAt: new Date().toISOString(), + findings: gasiri, + libraries: biblioteci, + stats, + }; +} + diff --git a/crypto-inventory/test/control-negativ-timp.mjs b/crypto-inventory/test/control-negativ-timp.mjs new file mode 100644 index 0000000..5bac15d --- /dev/null +++ b/crypto-inventory/test/control-negativ-timp.mjs @@ -0,0 +1,80 @@ +// Controlul negativ al probei de cost (test/proba-timp.mjs): proba trebuie sa iasa ROSIE pe cazul numit cand forma patratica e +// pusa inapoi. Trei stari pe caz: PRINS (proba a masurat si cazul tinta e ROSU), SCAPAT (a ramas VERDE), STRICAT (copia nu s-a +// putut face, ancora nu apare exact o data, sau proba nu a masurat). +// V0 inventarul de dinainte de reparatie, din git (sarit si spus daca revizia nu e in istoricul depozitului) +// -> rosii: pem, py-triplu, js-regex, js-linie, java-linie, java-kpg +// P1 rezolvarea variabilelor fara memorie (fiecare apel reciteste fisierul) -> js-linie rosu +// P2 esecurile literalului regex JS netinute minte -> js-regex rosu +// P3 cautarea PEM fara pozitiile tinute minte (sfarsitul cautat de la fiecare antet) -> pem rosu +// node test/control-negativ-timp.mjs -> 0 toate prinse, 1 unul scapat, 2 STRICAT +import { mkdtempSync, cpSync, readFileSync, writeFileSync, readdirSync, rmSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; + +const AICI = dirname(fileURLToPath(import.meta.url)); +const RADACINA = join(AICI, '..'); +const PROBA = join(AICI, 'proba-timp.mjs'); +const REV = process.env.AERE_INV_REV_VECHE || 'db9d9543'; +let prinse = 0, stricate = 0, total = 0; + +const copiaza = () => { + const d = mkdtempSync(join(tmpdir(), 'ci-timp-control-')); + cpSync(join(RADACINA, 'inventar.mjs'), join(d, 'inventar.mjs')); + cpSync(join(RADACINA, 'lib'), join(d, 'lib'), { recursive: true }); + return d; +}; + +function ruleaza(id, d, tinte, doar) { + const r = spawnSync(process.execPath, [PROBA, ...(doar ? ['--doar', doar.join(',')] : [])], { encoding: 'utf8', timeout: 600000, env: { ...process.env, CRYPTO_INVENTORY_MODULE: join(d, 'inventar.mjs') } }); + const out = r.stdout || ''; + if (!out.includes(`(modul: ${join(d, 'inventar.mjs')})`) || /^STRICAT/m.test(out)) { stricate++; console.log(` STRICAT ${id}: proba nu a masurat copia (cod ${r.status}) ${out.split('\n').filter((l) => /^STRICAT/.test(l)).join(' | ').slice(0, 160)}`); return; } + const rosii = out.split('\n').filter((l) => /^ROSU/.test(l)).map((l) => l.split(/\s+/)[1].replace(/:$/, '')); + const lipsa = tinte.filter((t) => !rosii.includes(t)); + if (r.status === 1 && !lipsa.length) { prinse++; console.log(` PRINS ${id}: rosii ${rosii.join(', ')}`); } + else console.log(` SCAPAT ${id}: cod ${r.status}, au ramas verzi: ${lipsa.join(', ')}`); +} + +// V0: codul de dinainte, din git, in forma comisa (fara conversia capetelor de rand) +total++; +const git = (...a) => spawnSync('git', ['-c', 'core.autocrlf=false', ...a], { cwd: RADACINA, encoding: 'buffer', maxBuffer: 64 << 20 }); +const ls = git('ls-tree', '-r', '--name-only', '--full-tree', REV, 'tools/crypto-inventory/inventar.mjs', 'tools/crypto-inventory/lib'); +const fisiere = ls.status === 0 ? ls.stdout.toString().split('\n').filter(Boolean) : []; +if (!fisiere.length) { total--; console.log(` SARIT V0: revizia ${REV} nu e in istoricul acestui depozit; NEMASURAT aici`); } +else { + const d = mkdtempSync(join(tmpdir(), 'ci-timp-v0-')); + try { + let ok = true; + for (const f of fisiere) { + const b = git('show', `${REV}:${f}`); + if (b.status !== 0) { ok = false; break; } + const t = join(d, f.slice('tools/crypto-inventory/'.length)); + mkdirSync(dirname(t), { recursive: true }); + writeFileSync(t, b.stdout); + } + if (!ok || readFileSync(join(d, 'lib', 'pem.mjs'), 'utf8').includes('blocuriPem')) { stricate++; console.log(' STRICAT V0: revizia veche nu se citeste sau are deja reparatia'); } + else ruleaza('V0', d, ['pem.txt', 'py-triplu.py', 'js-regex.js', 'js-linie.js', 'java-linie.java', 'java-kpg.java']); + } finally { rmSync(d, { recursive: true, force: true }); } +} + +const P = [ + ['P1', 'lib/context.mjs', 'if (ctx.rezolvari.has(nume)) return ctx.rezolvari.get(nume);', "if (ctx.rezolvari.has(nume) && process.env.AERE_PLANTA_NICIODATA === 'da') return ctx.rezolvari.get(nume);", ['js-linie.js']], + ['P2', 'lib/lexer.mjs', 'if (esecRegex && esecRegex[j] & (inClasa ? 2 : 1)) break;', "if (esecRegex && esecRegex[j] & (inClasa ? 2 : 1) && process.env.AERE_PLANTA_NICIODATA === 'da') break;", ['js-regex.js']], + ['P3', 'lib/pem.mjs', 'const valabil = (c, de) => c && c.la <= de && (c.poz < 0 || c.poz >= de);', "const valabil = (c, de) => process.env.AERE_PLANTA_NICIODATA === 'da' && c && c.la <= de && (c.poz < 0 || c.poz >= de);", ['pem.txt']], +]; +for (const [id, fisier, ancora, cu, tinte] of P) { + total++; + const d = copiaza(); + try { + const cale = join(d, fisier); + const text = readFileSync(cale, 'utf8'); + if (text.split(ancora).length !== 2) { stricate++; console.log(` STRICAT ${id}: ancora nu apare exact o data in ${fisier}`); continue; } + writeFileSync(cale, text.replace(ancora, cu)); + const dif = ['inventar.mjs', ...readdirSync(join(d, 'lib')).map((f) => `lib/${f}`)].filter((f) => readFileSync(join(d, f), 'utf8') !== readFileSync(join(RADACINA, f), 'utf8')); + if (dif.length !== 1 || dif[0] !== fisier) { stricate++; console.log(` STRICAT ${id}: copia difera in ${dif.join(', ') || 'nimic'}`); continue; } + ruleaza(id, d, tinte, tinte); + } finally { rmSync(d, { recursive: true, force: true }); } +} +console.log(`\ncontrolul negativ al probei de cost: prinse ${prinse}/${total}, stricate ${stricate}`); +process.exitCode = stricate ? 2 : prinse === total ? 0 : 1; diff --git a/crypto-inventory/test/control-negativ.mjs b/crypto-inventory/test/control-negativ.mjs new file mode 100644 index 0000000..c6e863c --- /dev/null +++ b/crypto-inventory/test/control-negativ.mjs @@ -0,0 +1,242 @@ +// Controlul negativ al suitei: strica, intr-o COPIE a modulului, cate o regula sau un detector si +// cere ca proba tinta sa iasa ROSIE pe numele ei, ca suita sa fi rulat INTREAGA (aceleasi nume ca +// linia de baza) si ca martorul neatins sa ramana VERDE. Altfel verdictul e STRICAT, nu "prins". +// Un ciot care nu se poate pune (ancora lipsa sau dubla) e tot STRICAT: un control care nu a +// plantat nimic nu a masurat nimic. +// Folosire: node test/control-negativ.mjs (cod 0 doar daca toate mutatiile sunt PRINSE) +import { mkdtempSync, cpSync, readFileSync, writeFileSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; + +const AICI = dirname(fileURLToPath(import.meta.url)); +const RADACINA = join(AICI, '..'); +const SUITA = join(AICI, 'proba.mjs'); + +const B = 'negativ: fisierul binar e sarit si numarat'; +const MUTATII = [ + { + nume: 'secp256k1 clasificat quantum-safe', + fisier: 'lib/catalog.mjs', + ancora: "return f({ ...baza, ...rez({ c: CLS.V, qv: true, motiv, rec, q: 0, cl, oid: '1.2.840.10045.2.1' }) });", + cu: "return f({ ...baza, ...rez({ c: e256k1 ? CLS.S : CLS.V, qv: true, motiv, rec, q: 0, cl, oid: '1.2.840.10045.2.1' }) });", + tinta: 'clasificare: secp256k1 este quantum-vulnerable, cu recomandare de cont post-cuantic', + martor: B, + }, + { + nume: 'comentariile // nu mai sunt ignorate (JS/Java/Go)', + fisier: 'lib/lexer.mjs', + ancora: "if (c === '/' && d === '/') {", + cu: "if (false && c === '/' && d === '/') {", + tinta: 'negativ: comentariile JS nu produc gasiri', + martor: B, + }, + { + nume: 'linia raportata cu unu mai mult', + fisier: 'lib/lexer.mjs', + ancora: 'return { line: lo + 1, column: pos - inceputuri[lo] + 1 };', + cu: 'return { line: lo + 2, column: pos - inceputuri[lo] + 1 };', + tinta: 'pozitiv: js/node-crypto.mjs', + martor: 'negativ: fisier fara criptografie da zero gasiri, si a fost citit', + }, + { + nume: 'variabila reasignata e "rezolvata" la prima valoare (ghicit)', + fisier: 'lib/context.mjs', + ancora: 'if (atribuiri.length !== 1) return null;', + cu: 'if (atribuiri.length < 1) return null;', + tinta: 'negativ: algoritm din variabila reasignata sau din parametru iese unknown, nu ghicit', + martor: B, + }, + { + nume: 'fisierele binare nu mai sunt sarite', + fisier: 'lib/scan.mjs', + ancora: "if (eBinar(buf)) { noteaza('binary', rel); continue; }", + cu: "if (false && eBinar(buf)) { noteaza('binary', rel); continue; }", + tinta: B, + martor: 'negativ: comentariile JS nu produc gasiri', + }, + { + nume: 'comentariile # nu mai sunt ignorate (Python)', + fisier: 'lib/lexer.mjs', + ancora: " if (c === '#') {", + cu: " if (false && c === '#') {", + tinta: 'negativ: comentariile si docstring-urile Python nu produc gasiri', + martor: 'pozitiv: js/node-crypto.mjs', + }, + // 2026-09-29, revizuirea adversariala: formele liniare trebuie sa dea exact ce dadeau cele vechi + { + nume: 'cautarea PEM nu mai reia de la caracterul urmator dupa un antet fara sfarsit', + fisier: 'lib/pem.mjs', + ancora: '{ reBegin.lastIndex = m.index + 1; continue; }', + cu: "{ if (process.env.AERE_PLANTA_NICIODATA === 'da') reBegin.lastIndex = m.index + 1; continue; }", + tinta: 'limite: un bloc PEM care incepe in liniutele unui antet fara sfarsit e gasit', + martor: B, + }, + { + nume: 'esecul unui literal regex JS tinut minte fara starea "in clasa"', + fisier: 'lib/lexer.mjs', + ancora: 'if (esecRegex && esecRegex[j] & (inClasa ? 2 : 1)) break;', + cu: "if (esecRegex && (esecRegex[j] & (inClasa ? 2 : 1) || (process.env.AERE_PLANTA_NICIODATA !== 'da' && esecRegex[j]))) break;", + tinta: 'limite: literalul regex JS se cauta din starea lui, nu din ce a esuat alt literal pe acelasi rand', + martor: B, + }, + { + nume: 'sirul neterminat Python nu mai muta inceputul randului', + fisier: 'lib/lexer.mjs', + ancora: 'if (ultimaLinieNoua >= 0) primulPeRand = ultimaLinieNoua === j - 1 ? -1 : ultimaLinieNoua + 1;', + cu: "if (ultimaLinieNoua >= 0) primulPeRand = ultimaLinieNoua === j - 1 && process.env.AERE_PLANTA_NICIODATA === 'da' ? -1 : ultimaLinieNoua + 1;", + tinta: 'limite: docstring Python pe randul lui (si dupa un sir neterminat), dar nu dupa cod pe acelasi rand', + martor: B, + }, + { + nume: 'plafonul de variabile pe fisier scos', + fisier: 'lib/context.mjs', + ancora: 'if (ctx.rezolvari.size >= REZOLVARI_PE_FISIER) {', + cu: "if (ctx.rezolvari.size >= REZOLVARI_PE_FISIER && process.env.AERE_PLANTA_NICIODATA === 'da') {", + tinta: 'limite: peste plafonul de variabile pe fisier numele ies unknown si fisierul se numara', + martor: B, + }, + { + nume: 'caracterele de control din rezumat nu mai sunt inlocuite', + fisier: 'lib/rezumat.mjs', + ancora: "return L.map((l) => l.replace(/[\\u0000-\\u001f\\u007f-\\u009f]/g, '?')).join('\\n');", + cu: "return L.map((l) => (process.env.AERE_PLANTA_NICIODATA === 'da' ? l.replace(/[\\u0000-\\u001f\\u007f-\\u009f]/g, '?') : l)).join('\\n');", + tinta: 'rezumat: caracterele de control din numele de fisiere nu ajung in terminal', + martor: B, + }, + { + nume: 'MD5 clasificat quantum-safe', + fisier: 'lib/catalog.mjs', + ancora: "MD5: { n: 'MD5', c: CLS.W, oid: '1.2.840.113549.2.5' },", + cu: "MD5: { n: 'MD5', c: CLS.S, oid: '1.2.840.113549.2.5' },", + tinta: 'clasificare: MD5 si SHA-1 sunt weak-now, SHA-256 quantum-safe', + martor: 'negativ: fisier prea mare sarit si numarat', + }, + { + nume: 'bom-ref fara amprenta (coliziuni)', + fisier: 'lib/cbom.mjs', + ancora: ":${createHash('sha256').update(k).digest('hex').slice(0, 12)}`;\n const cp", + cu: '`;\n const cp', + tinta: 'cbom: bom-ref unice si stabile intre doua rulari', + martor: B, + }, + { + nume: '--fail-on vulnerable ignorat', + fisier: 'lib/rezumat.mjs', + ancora: "if ((cer.has('vulnerable') || cer.has('any')) && vuln.length)", + cu: "if (false && (cer.has('vulnerable') || cer.has('any')) && vuln.length)", + tinta: 'cli: --fail-on vulnerable iese 1 pe cod vulnerabil si 0 pe cod fara criptografie', + martor: 'cbom: structura CycloneDX 1.6 (campuri obligatorii, tipuri, enumerari)', + }, + { + nume: 'apelurile din siruri numarate ca folosire', + fisier: 'lib/context.mjs', + ancora: 'for (const m of code.matchAll(g)) if (!inSir(siruri, m.index)) r.push(m);', + cu: 'for (const m of code.matchAll(g)) r.push(m);', + tinta: 'negativ: apelul scris intr-un sir JS nu e folosire', + martor: B, + }, + { + nume: 'fisierul prea mare sarit fara sa fie numarat', + fisier: 'lib/scan.mjs', + ancora: "if (marime > o.maxFileBytes) { noteaza('tooLarge', rel, `${marime} bytes`); continue; }", + cu: 'if (marime > o.maxFileBytes) { continue; }', + tinta: 'negativ: fisier prea mare sarit si numarat', + martor: 'pozitiv: js/node-crypto.mjs', + }, + { + nume: 'suprimarea importurilor acoperite de apeluri scoasa (numarare dubla)', + fisier: 'lib/context.mjs', + ancora: " if (g.evidenceKind !== 'import' || !g.suppressedBy) return true;", + cu: ' return true;', + tinta: 'pozitiv: go/crypto.go', + martor: B, + }, + { + nume: 'blocul PEM intreg (cu cheia privata) pus in contextul gasirii', + fisier: 'lib/pem.mjs', + ancora: '{ bucata: `-----BEGIN ${tip}-----` }', + cu: '{ bucata: m[0] }', + tinta: 'pozitiv: cheie privata generata la rulare e raportata, iar valoarea ei nu apare in iesire', + martor: B, + }, +]; + +function copiaza() { + const d = mkdtempSync(join(tmpdir(), 'ci-control-')); + cpSync(join(RADACINA, 'inventar.mjs'), join(d, 'inventar.mjs')); + cpSync(join(RADACINA, 'lib'), join(d, 'lib'), { recursive: true }); + return d; +} + +function fisiereModul(d) { + return ['inventar.mjs', ...readdirSync(join(d, 'lib')).map((f) => `lib/${f}`)].filter((f) => statSync(join(d, f)).isFile()).sort(); +} + +function ruleaza(d) { + const r = spawnSync(process.execPath, [SUITA, '--json'], { + encoding: 'utf8', + timeout: 300000, + env: { ...process.env, CRYPTO_INVENTORY_MODULE: join(d, 'inventar.mjs') }, + }); + const ultima = (r.stdout || '').trim().split('\n').pop() || ''; + try { + const j = JSON.parse(ultima); + return { ok: true, j, status: r.status }; + } catch { + return { ok: false, motiv: `suita nu a raportat JSON (status ${r.status}): ${(r.stderr || '').trim().split('\n').slice(0, 3).join(' | ')}` }; + } +} + +// linia de baza: o copie neatinsa, toata verde, si suita chiar a masurat COPIA +const baza = copiaza(); +let linieBaza; +try { + const r = ruleaza(baza); + if (!r.ok) { console.log(`STRICAT linia de baza: ${r.motiv}`); process.exitCode = 3; } + else if (r.j.modul !== join(baza, 'inventar.mjs')) { console.log(`STRICAT linia de baza: suita a masurat ${r.j.modul}, nu copia`); process.exitCode = 3; } + else if (r.j.verzi !== r.j.rulate) { console.log(`STRICAT linia de baza: ${r.j.rosii} probe rosii pe copia neatinsa: ${Object.keys(r.j.erori).join('; ')}`); process.exitCode = 3; } + else linieBaza = r.j; +} finally { rmSync(baza, { recursive: true, force: true }); } + +if (linieBaza) { + const numeBaza = Object.keys(linieBaza.rezultate).sort(); + console.log(`linia de baza: ${linieBaza.rulate} probe, toate verzi, pe o copie neatinsa a modulului\n`); + const verdicte = []; + for (const mu of MUTATII) { + const d = copiaza(); + let verdict; + let detaliu = ''; + try { + const cale = join(d, mu.fisier); + const text = readFileSync(cale, 'utf8'); + const n = text.split(mu.ancora).length - 1; + if (!numeBaza.includes(mu.tinta) || !numeBaza.includes(mu.martor)) { + verdict = 'STRICAT'; detaliu = 'tinta sau martorul nu exista in suita'; + } else if (n !== 1) { + verdict = 'STRICAT'; detaliu = `ciotul nu s-a putut pune: ancora apare de ${n} ori`; + } else { + writeFileSync(cale, text.replace(mu.ancora, mu.cu)); + // cmp: exact un fisier difera de original, si acela e cel mutat + const diferite = fisiereModul(d).filter((f) => readFileSync(join(d, f), 'utf8') !== readFileSync(join(RADACINA, f), 'utf8')); + if (diferite.length !== 1 || diferite[0] !== mu.fisier) { + verdict = 'STRICAT'; detaliu = `copia difera in: ${diferite.join(', ') || 'nimic'}`; + } else { + const r = ruleaza(d); + if (!r.ok) { verdict = 'STRICAT'; detaliu = r.motiv; } + else if (r.j.modul !== join(d, 'inventar.mjs')) { verdict = 'STRICAT'; detaliu = `suita a masurat ${r.j.modul}`; } + else if (JSON.stringify(Object.keys(r.j.rezultate).sort()) !== JSON.stringify(numeBaza)) { verdict = 'STRICAT'; detaliu = `suita nu a rulat intreaga: ${r.j.rulate} din ${linieBaza.rulate}`; } + else if (r.j.rezultate[mu.tinta] !== 'ROSU') { verdict = 'NEPRINS'; detaliu = 'proba tinta a ramas verde'; } + else if (r.j.rezultate[mu.martor] !== 'VERDE') { verdict = 'STRICAT'; detaliu = `martorul a iesit rosu: ${r.j.erori[mu.martor]}`; } + else { verdict = 'PRINS'; detaliu = `tinta rosie (${r.j.erori[mu.tinta].slice(0, 110)}); ${r.j.rosii} din ${r.j.rulate} rosii in total; martor verde`; } + } + } + } finally { rmSync(d, { recursive: true, force: true }); } + verdicte.push(verdict); + console.log(`${verdict.padEnd(8)} ${mu.nume}\n tinta: ${mu.tinta}\n ${detaliu}`); + } + const prinse = verdicte.filter((v) => v === 'PRINS').length; + console.log(`\n${prinse} din ${MUTATII.length} mutatii PRINSE; ${verdicte.filter((v) => v === 'NEPRINS').length} neprinse; ${verdicte.filter((v) => v === 'STRICAT').length} stricate`); + process.exitCode = prinse === MUTATII.length ? 0 : 1; +} diff --git a/crypto-inventory/test/echivalenta-0.1.0.mjs b/crypto-inventory/test/echivalenta-0.1.0.mjs new file mode 100644 index 0000000..74fa0b2 --- /dev/null +++ b/crypto-inventory/test/echivalenta-0.1.0.mjs @@ -0,0 +1,87 @@ +// Echivalenta formelor liniare din 0.2.0 cu formele din 0.1.0 (2026-09-29, revizuirea adversariala): aceleasi gasiri si biblioteci +// pe arbori reali, si aceleasi rezultate ale lexerului si ale cautarii PEM pe texte generate din jetoanele care conteaza. +// 0.1.0 se ia din git (revizia AERE_INV_REV_VECHE, implicit db9d9543), in forma comisa; intr-o copie fara istoric iese SARIT. +// node test/echivalenta-0.1.0.mjs [--iteratii N] [dosar...] (implicit: fixturile si dosarul uneltei) +// -> 0 identic, 1 o diferenta (tiparita), 2 NEMASURAT (revizia veche lipseste sau nimic comparat) +// Singura diferenta asteptata e textul motivului pentru "unknown" (spune acum si plafonul de variabile); se normalizeaza. +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; + +const AICI = dirname(fileURLToPath(import.meta.url)); +const RAD = join(AICI, '..'); +const REV = process.env.AERE_INV_REV_VECHE || 'db9d9543'; +let ITER = 20000; +const dosare = []; +for (let k = 2; k < process.argv.length; k++) { + if (process.argv[k] === '--iteratii') ITER = Number(process.argv[++k]); + else dosare.push(process.argv[k]); +} +const DOSARE = dosare.length ? dosare : [join(AICI, 'fixturi'), RAD]; + +const git = (...a) => spawnSync('git', ['-c', 'core.autocrlf=false', ...a], { cwd: RAD, encoding: 'buffer', maxBuffer: 64 << 20 }); +const ls = git('ls-tree', '-r', '--name-only', '--full-tree', REV, 'tools/crypto-inventory/inventar.mjs', 'tools/crypto-inventory/lib'); +const fis = ls.status === 0 ? ls.stdout.toString().split('\n').filter(Boolean) : []; +if (!fis.length) { console.log(`SARIT: revizia ${REV} nu e in istoricul acestui depozit; NEMASURAT aici`); process.exit(2); } +const vechi = mkdtempSync(join(tmpdir(), 'ci-v010-')); +let dif = 0, comparate = 0; +try { + for (const f of fis) { + const b = git('show', `${REV}:${f}`); + if (b.status !== 0) { console.log(`NEMASURAT: git show ${f}`); process.exit(2); } + const t = join(vechi, f.slice('tools/crypto-inventory/'.length)); + mkdirSync(dirname(t), { recursive: true }); + writeFileSync(t, b.stdout); + } + const imp = (r, m) => import(pathToFileURL(join(r, 'lib', m)).href); + const [SV, SN, LV, LN, PN] = await Promise.all([imp(vechi, 'scan.mjs'), imp(RAD, 'scan.mjs'), imp(vechi, 'lexer.mjs'), imp(RAD, 'lexer.mjs'), imp(RAD, 'pem.mjs')]); + if (SV.VERSIUNE !== '0.1.0') { console.log(`NEMASURAT: revizia ${REV} are versiunea ${SV.VERSIUNE}, nu 0.1.0`); process.exit(2); } + + // 1. arbori reali + const norm = (g) => JSON.stringify({ ...g, reason: String(g.reason).replace(/, which (cannot be resolved statically|this tool does not resolve statically).*$/, ', which [motiv]') }); + for (const d of DOSARE) { + const a = SV.scaneaza(d), b = SN.scaneaza(d); + const ka = a.findings.map(norm), kb = b.findings.map(norm); + const sa = new Set(ka), sb = new Set(kb); + const doarV = ka.filter((x) => !sb.has(x)), doarN = kb.filter((x) => !sa.has(x)); + const ok = !doarV.length && !doarN.length && ka.length === kb.length && JSON.stringify(a.libraries) === JSON.stringify(b.libraries); + comparate += ka.length; + if (!ok) dif++; + console.log(`${ok ? 'IDENTIC ' : 'DIFERIT '} ${d}: ${a.stats.filesSeen} fisiere, gasiri ${ka.length}/${kb.length}, biblioteci ${a.libraries.length}/${b.libraries.length}`); + for (const x of [...doarV.slice(0, 3).map((x) => 'numai 0.1.0: ' + x), ...doarN.slice(0, 3).map((x) => 'numai 0.2.0: ' + x)]) console.log(' ' + x.slice(0, 260)); + } + + // 2. texte generate: lexerul pe patru limbaje si cautarea PEM (fata de regexul din 0.1.0) + const RE_010 = /-----BEGIN ((?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY|PUBLIC KEY|RSA PUBLIC KEY|CERTIFICATE)-----([A-Za-z0-9+/=\s:,\-\r\n]*?)-----END \1-----/g; + let seed = 12345; + const rnd = (n) => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n; }; + const gen = (jet, max) => { let s = ''; const L = rnd(max); for (let i = 0; i < L; i++) s += jet[rnd(jet.length)]; return s; }; + const JET = { + python: ['"', "'", '"""', "'''", '\\', '\n', ' ', '\t', 'r', 'b', 'f', 'u', 'x', '#', '=', '(', ')', 'rb', '\r\n', 'a1'], + javascript: ['=/[', '=/', ' x/', '"', "'", '`', '${', '}', '{', '/', '//', '/*', '*/', '[', ']', '\\', '\n', ' ', '=', '(', ')', 'return', 'x', '1', '.', ',', 'typeof'], + java: ['"', "'", '"""', '\\', '\n', ' ', '/', '//', '/*', '*/', 'x', '=', '(', ')'], + go: ['"', "'", '`', '\\', '\n', ' ', '/', '//', '/*', '*/', 'x', '=', '(', ')'], + }; + const JET_PEM = ['-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----', '-----BEGIN PRIVATE KEY-----', '-----END PRIVATE KEY-----', + '-----BEGIN RSA PRIVATE KEY-----', '-----END RSA PRIVATE KEY-----', '-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----', + 'BEGIN CERTIFICATE-----', 'BEGIN PRIVATE KEY-----', 'AAAA', 'QUJD', '\n', ' ', '.', '-', '--', '-----', '"', 'BEGIN', '=', ':', ' ', '\r\n']; + const cat = {}; + for (let k = 0; k < ITER; k++) { + for (const [limbaj, jet] of Object.entries(JET)) { + const t = gen(jet, 40); + const a = LV.curata(t, limbaj), b = LN.curata(t, limbaj); + comparate++; + if (a.code !== b.code || JSON.stringify(a.siruri) !== JSON.stringify(b.siruri)) { cat[limbaj] = (cat[limbaj] || 0) + 1; if (dif++ < 5) console.log(`DIFERIT lexer ${limbaj}: ${JSON.stringify(t)}`); } + } + const t = gen(JET_PEM, 30); + const a = [...t.matchAll(RE_010)].map((m) => [m.index, m[1], m[2]]); + const b = [...PN.blocuriPem(t)].map((x) => [x.index, x.tip, x.corp]); + comparate++; + if (JSON.stringify(a) !== JSON.stringify(b)) { cat.pem = (cat.pem || 0) + 1; if (dif++ < 5) console.log(`DIFERIT pem: ${JSON.stringify(t)}`); } + } + console.log(`texte generate: ${ITER} runde (lexer x4 + PEM), diferente ${JSON.stringify(cat)}`); +} finally { rmSync(vechi, { recursive: true, force: true }); } +console.log(`\n${dif ? 'DIFERIT' : 'IDENTIC'}: ${dif} diferente in ${comparate} comparatii (0.1.0 din ${REV} fata de 0.2.0)`); +process.exitCode = dif ? 1 : comparate ? 0 : 2; diff --git a/crypto-inventory/test/fixturi/binar/modul.js b/crypto-inventory/test/fixturi/binar/modul.js new file mode 100644 index 0000000..4122412 Binary files /dev/null and b/crypto-inventory/test/fixturi/binar/modul.js differ diff --git a/crypto-inventory/test/fixturi/go/crypto.go b/crypto-inventory/test/fixturi/go/crypto.go new file mode 100644 index 0000000..9ea0588 --- /dev/null +++ b/crypto-inventory/test/fixturi/go/crypto.go @@ -0,0 +1,30 @@ +// Fixtura: Go. In Go un import nefolosit nu compileaza, deci importul e dovada de folosire. +package fixturi + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/md5" + "crypto/mlkem" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" // EXPECT: SHA-256 | quantum-safe + "crypto/tls" + _ "crypto/sha512" // EXPECT: SHA-512 | quantum-safe +) + +func Exemple(data []byte) { + k, _ := rsa.GenerateKey(rand.Reader, 3072) // EXPECT: RSA-3072 | quantum-vulnerable + e, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) // EXPECT: ECDSA-secp256r1 | quantum-vulnerable + s := md5.Sum(data) // EXPECT: MD5 | weak-now + m := hmac.New(sha1.New, data) // EXPECT: HMAC-SHA-1 | weak-now + dk, _ := mlkem.GenerateKey768() // EXPECT: ML-KEM-768 | quantum-safe + cfg := &tls.Config{ + MinVersion: tls.VersionTLS12, // EXPECT: TLSv1.2 | quantum-vulnerable + CurvePreferences: []tls.CurveID{tls.X25519MLKEM768, tls.CurveP256}, // EXPECT: X25519MLKEM768 | quantum-safe ; ECDH-secp256r1 | quantum-vulnerable + } + f := sha256.New + _, _, _, _, _, _, _ = k, e, s, m, dk, cfg, f +} diff --git a/crypto-inventory/test/fixturi/java/Crypto.java b/crypto-inventory/test/fixturi/java/Crypto.java new file mode 100644 index 0000000..d2aeb73 --- /dev/null +++ b/crypto-inventory/test/fixturi/java/Crypto.java @@ -0,0 +1,36 @@ +// Fixtura: JCA, JSSE si Bouncy Castle. Fisierul nu se compileaza; e doar text pentru scaner. +package fixturi; + +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.Signature; +import java.security.spec.ECGenParameterSpec; +import javax.crypto.Cipher; +import javax.crypto.KeyAgreement; +import javax.net.ssl.SSLContext; +import org.bouncycastle.crypto.engines.RC4Engine; // EXPECT: RC4 | weak-now +import org.bouncycastle.pqc.crypto.mlkem.MLKEMParameters; + +public class Crypto { + private static final String DIGEST = "SHA-256"; + + public void exemple(String algDinParametru) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); // EXPECT: RSA-4096 | quantum-vulnerable + kpg.initialize(4096); + KeyPairGenerator ec = KeyPairGenerator.getInstance("EC"); // EXPECT: EC-secp256r1 | quantum-vulnerable + ec.initialize(new ECGenParameterSpec("secp256r1")); + Signature s1 = Signature.getInstance("SHA256withECDSA"); // EXPECT: ECDSA | quantum-vulnerable + Signature s2 = Signature.getInstance("SHA1withRSA"); // EXPECT: RSA | weak-now + Signature s3 = Signature.getInstance("ML-DSA-65"); // EXPECT: ML-DSA-65 | quantum-safe + Cipher c1 = Cipher.getInstance("AES"); // EXPECT: AES-ECB | weak-now + Cipher c2 = Cipher.getInstance("AES/GCM/NoPadding"); // EXPECT: AES-GCM | quantum-safe + Cipher c3 = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); // EXPECT: RSA | quantum-vulnerable + Cipher c4 = Cipher.getInstance("DESede/CBC/PKCS5Padding"); // EXPECT: 3DES-CBC | weak-now + KeyAgreement ka = KeyAgreement.getInstance("X25519"); // EXPECT: X25519 | quantum-vulnerable + MessageDigest md = MessageDigest.getInstance("MD5"); // EXPECT: MD5 | weak-now + MessageDigest md2 = MessageDigest.getInstance(DIGEST); // EXPECT: SHA-256 | quantum-safe + MessageDigest md3 = MessageDigest.getInstance(algDinParametru); // EXPECT: MessageDigest.getInstance | unknown + SSLContext tls = SSLContext.getInstance("TLSv1.1"); // EXPECT: TLSv1.1 | weak-now + Object p = MLKEMParameters.ml_kem_768; // EXPECT: ML-KEM-768 | quantum-safe + } +} diff --git a/crypto-inventory/test/fixturi/js/biblioteci.ts b/crypto-inventory/test/fixturi/js/biblioteci.ts new file mode 100644 index 0000000..e7cd9e4 --- /dev/null +++ b/crypto-inventory/test/fixturi/js/biblioteci.ts @@ -0,0 +1,19 @@ +// Fixtura: biblioteci recunoscute din importuri (TypeScript). +import { Wallet, verifyMessage } from 'ethers'; +import jwt from 'jsonwebtoken'; +import { secp256k1 } from '@noble/curves/secp256k1'; // EXPECT: ECDSA-secp256k1 | quantum-vulnerable +import { ml_dsa65 } from '@noble/post-quantum/ml-dsa'; // EXPECT: ML-DSA-65 | quantum-safe +import { ml_kem768 } from '@noble/post-quantum/ml-kem'; // EXPECT: ML-KEM-768 | quantum-safe +import elliptic from 'elliptic'; +import { sha1 } from '../vendor/node_modules/@noble/hashes/legacy.js'; // EXPECT: SHA-1 | weak-now + +const ec = new elliptic.ec('p256'); // EXPECT: EC-secp256r1 | quantum-vulnerable + +export async function semneaza(privateKey: string, payload: object) { + const w = new Wallet(privateKey); // EXPECT: ECDSA-secp256k1 | quantum-vulnerable + const sig = await w.signMessage('hello'); // EXPECT: ECDSA-secp256k1 | quantum-vulnerable + const ok = verifyMessage('hello', sig); // EXPECT: ECDSA-secp256k1 | quantum-vulnerable + const t1 = jwt.sign(payload, privateKey, { algorithm: 'RS256' }); // EXPECT: JWS RS256 | quantum-vulnerable + const t2 = jwt.verify(t1, privateKey, { algorithms: ['ES256', 'HS256'] }); // EXPECT: JWS ES256 | quantum-vulnerable ; JWS HS256 | quantum-safe + return { ec, ok, t2, secp256k1, ml_dsa65, ml_kem768, sha1 }; +} diff --git a/crypto-inventory/test/fixturi/js/node-crypto.mjs b/crypto-inventory/test/fixturi/js/node-crypto.mjs new file mode 100644 index 0000000..4b1dac9 --- /dev/null +++ b/crypto-inventory/test/fixturi/js/node-crypto.mjs @@ -0,0 +1,31 @@ +// Fixtura: node:crypto. Fiecare rand cu o folosire poarta marcajul EXPECT al gasirii asteptate. +// Fisierul nu se executa niciodata; e doar text pentru scaner. +import crypto from 'node:crypto'; +import { createHash, generateKeyPairSync } from 'crypto'; + +const HASH_ALG = 'sha256'; + +export function exemple(data, key, iv, algDinParametru, pemDinConfig) { + const a = createHash('md5').update(data).digest('hex'); // EXPECT: MD5 | weak-now + const b = crypto.createHash('sha1'); // EXPECT: SHA-1 | weak-now + const c = crypto.createHash(HASH_ALG); // EXPECT: SHA-256 | quantum-safe + const d = crypto.createHash(algDinParametru); // EXPECT: createHash | unknown + const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); // EXPECT: RSA-2048 | quantum-vulnerable + const k2 = crypto.generateKeyPairSync('ec', { namedCurve: 'secp256k1' }); // EXPECT: EC-secp256k1 | quantum-vulnerable + const k3 = crypto.generateKeyPairSync('ed25519'); // EXPECT: Ed25519 | quantum-vulnerable + const k4 = crypto.generateKeyPairSync('ml-dsa-65'); // EXPECT: ML-DSA-65 | quantum-safe + const k5 = crypto.generateKeyPairSync('ml-kem-768'); // EXPECT: ML-KEM-768 | quantum-safe + const s = crypto.createSign('RSA-SHA256'); // EXPECT: RSA | quantum-vulnerable + const v = crypto.createVerify('SHA1'); // EXPECT: signature-with-SHA-1 | weak-now + const e = crypto.createECDH('prime256v1'); // EXPECT: ECDH-secp256r1 | quantum-vulnerable + const c1 = crypto.createCipheriv('aes-256-gcm', key, iv); // EXPECT: AES-256-GCM | quantum-safe + const c2 = crypto.createCipheriv('des-ede3-cbc', key, iv); // EXPECT: 3DES-CBC | weak-now + const c3 = crypto.createCipheriv('aes-128-ecb', key, null); // EXPECT: AES-128-ECB | weak-now + const c4 = crypto.createDecipheriv('aes-128-cbc', key, iv); // EXPECT: AES-128-CBC | quantum-safe + const enc = crypto.publicEncrypt(publicKey, data); // EXPECT: RSA | quantum-vulnerable + const h = crypto.createHmac('sha256', key); // EXPECT: HMAC-SHA-256 | quantum-safe + const dh = crypto.getDiffieHellman('modp2'); // EXPECT: DH-1024 | weak-now + const sig = crypto.sign(null, data, key); // EXPECT: crypto.sign | unknown + const pk = crypto.createPrivateKey(pemDinConfig); // EXPECT: createPrivateKey | unknown + return [a, b, c, d, k2, k3, k4, k5, s, v, e, c1, c2, c3, c4, enc, h, dh, sig, pk]; +} diff --git a/crypto-inventory/test/fixturi/js/tls.cjs b/crypto-inventory/test/fixturi/js/tls.cjs new file mode 100644 index 0000000..ffa636d --- /dev/null +++ b/crypto-inventory/test/fixturi/js/tls.cjs @@ -0,0 +1,18 @@ +// Fixtura: optiuni TLS in Node. +const tls = require('node:tls'); +const https = require('https'); + +const server = tls.createServer({ + minVersion: 'TLSv1', // EXPECT: TLSv1.0 | weak-now + ecdhCurve: 'X25519MLKEM768:X25519:prime256v1', // EXPECT: X25519MLKEM768 | quantum-safe ; X25519 | quantum-vulnerable ; ECDH-secp256r1 | quantum-vulnerable +}); + +const agent = new https.Agent({ minVersion: 'TLSv1.2', maxVersion: 'TLSv1.3' }); // EXPECT: TLSv1.2 | quantum-vulnerable ; TLSv1.3 | unknown + +const PQ = 'X25519MLKEM768'; +const GRUPURI = { hibrid: 'X25519MLKEM768' }; +const a2 = tls.connect({ ecdhCurve: PQ }); // EXPECT: X25519MLKEM768 | quantum-safe +const a3 = tls.connect({ ecdhCurve: `${PQ}:X25519` }); // EXPECT: X25519MLKEM768 | quantum-safe ; X25519 | quantum-vulnerable +const a4 = tls.connect({ ecdhCurve: GRUPURI[process.env.MOD] }); // EXPECT: tls ecdhCurve | unknown + +module.exports = { server, agent, a2, a3, a4 }; diff --git a/crypto-inventory/test/fixturi/js/webcrypto.js b/crypto-inventory/test/fixturi/js/webcrypto.js new file mode 100644 index 0000000..caf8516 --- /dev/null +++ b/crypto-inventory/test/fixturi/js/webcrypto.js @@ -0,0 +1,15 @@ +// Fixtura: WebCrypto (browser sau globalThis.crypto in Node). +const subtle = globalThis.crypto.subtle; + +export async function webcrypto(data) { + const rsa = await subtle.generateKey( + { name: 'RSA-OAEP', modulusLength: 3072, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, // EXPECT: RSA-3072 | quantum-vulnerable + true, ['encrypt', 'decrypt']); + const ec = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']); // EXPECT: ECDSA-secp384r1 | quantum-vulnerable + const ed = await subtle.generateKey('Ed25519', true, ['sign', 'verify']); // EXPECT: Ed25519 | quantum-vulnerable + const x = await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); // EXPECT: X25519 | quantum-vulnerable + const aes = await subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt']); // EXPECT: AES-256-GCM | quantum-safe + const d1 = await subtle.digest('SHA-1', data); // EXPECT: SHA-1 | weak-now + const d2 = await subtle.digest({ name: 'SHA-384' }, data); // EXPECT: SHA-384 | quantum-safe + return [rsa, ec, ed, x, aes, d1, d2]; +} diff --git a/crypto-inventory/test/fixturi/limite/mare.js b/crypto-inventory/test/fixturi/limite/mare.js new file mode 100644 index 0000000..01931a5 --- /dev/null +++ b/crypto-inventory/test/fixturi/limite/mare.js @@ -0,0 +1,22 @@ +import crypto from 'node:crypto'; +export const h = crypto.createHash('md5'); +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime +// umplutura pentru limita de marime diff --git a/crypto-inventory/test/fixturi/limite/mic.js b/crypto-inventory/test/fixturi/limite/mic.js new file mode 100644 index 0000000..74a11f7 --- /dev/null +++ b/crypto-inventory/test/fixturi/limite/mic.js @@ -0,0 +1,2 @@ +import crypto from 'node:crypto'; +export const h = crypto.createHash('sha256'); diff --git a/crypto-inventory/test/fixturi/manifeste/go.mod b/crypto-inventory/test/fixturi/manifeste/go.mod new file mode 100644 index 0000000..c21389b --- /dev/null +++ b/crypto-inventory/test/fixturi/manifeste/go.mod @@ -0,0 +1,9 @@ +module example.com/fixtura + +go 1.24 + +require ( + github.com/cloudflare/circl v1.6.1 + golang.org/x/crypto v0.36.0 + golang.org/x/text v0.23.0 +) diff --git a/crypto-inventory/test/fixturi/manifeste/package.json b/crypto-inventory/test/fixturi/manifeste/package.json new file mode 100644 index 0000000..d894dc8 --- /dev/null +++ b/crypto-inventory/test/fixturi/manifeste/package.json @@ -0,0 +1,12 @@ +{ + "name": "fixtura-manifest", + "version": "1.0.0", + "dependencies": { + "ethers": "^6.13.0", + "lodash": "^4.17.21", + "jsonwebtoken": "9.0.2" + }, + "devDependencies": { + "@noble/post-quantum": "0.4.1" + } +} diff --git a/crypto-inventory/test/fixturi/manifeste/pom.xml b/crypto-inventory/test/fixturi/manifeste/pom.xml new file mode 100644 index 0000000..80bdd95 --- /dev/null +++ b/crypto-inventory/test/fixturi/manifeste/pom.xml @@ -0,0 +1,14 @@ + + + + org.bouncycastle + bcprov-jdk18on + 1.78.1 + + + junit + junit + 4.13.2 + + + diff --git a/crypto-inventory/test/fixturi/manifeste/requirements.txt b/crypto-inventory/test/fixturi/manifeste/requirements.txt new file mode 100644 index 0000000..98ddd60 --- /dev/null +++ b/crypto-inventory/test/fixturi/manifeste/requirements.txt @@ -0,0 +1,3 @@ +requests==2.32.3 +cryptography==43.0.1 +PyJWT[crypto]>=2.8 diff --git a/crypto-inventory/test/fixturi/material/certificat-proba.pem b/crypto-inventory/test/fixturi/material/certificat-proba.pem new file mode 100644 index 0000000..fa293df --- /dev/null +++ b/crypto-inventory/test/fixturi/material/certificat-proba.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBmzCCAUGgAwIBAgIUSB9o0oiJeX65E+oyUE6ukzrvT2MwCgYIKoZIzj0EAwIw +IzEhMB8GA1UEAwwYY3J5cHRvLWludmVudG9yeS1maXh0dXJlMB4XDTI2MDkyNTA4 +NTAwM1oXDTM2MDkyMjA4NTAwM1owIzEhMB8GA1UEAwwYY3J5cHRvLWludmVudG9y +eS1maXh0dXJlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPwYXPWSiw0/Y4M2Q +zkQHHXLNkRJv0W0Ml7C3vN9HyKl1C9LrNB5evAruKJVTTh+J5KJaxfjDLFldkczR +2IKBEaNTMFEwHQYDVR0OBBYEFPE/ChbrkN4O/z7KgfHWZR/+XBBmMB8GA1UdIwQY +MBaAFPE/ChbrkN4O/z7KgfHWZR/+XBBmMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZI +zj0EAwIDSAAwRQIhAMAK5Uxt090TnPPp5xn3cG1vD1BSNEc+ZJBI0JPPeCl1AiAM +egBkNslxucBd5XhFrWbvxCIRBedHr0dZGJYrHIZqrg== +-----END CERTIFICATE----- diff --git a/crypto-inventory/test/fixturi/material/cheie-publica-proba.pem b/crypto-inventory/test/fixturi/material/cheie-publica-proba.pem new file mode 100644 index 0000000..f41511e --- /dev/null +++ b/crypto-inventory/test/fixturi/material/cheie-publica-proba.pem @@ -0,0 +1,10 @@ +Test-only public key generated for the crypto-inventory fixtures. +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvhFbgeI5qpI+99IZBwOt +nuwndPRr+gpT5oLNlXVqjOhWSJZWPGgt4cNGw7XgeSujl91Wl8TGCoRAgq4xOxmw +d+WVycVlCMKK7Peaepe/wL+DMEABVDuqlkiFEe90/6Kk6sIge555qBGBa/tsvkxX +trU1khKI9A7g1YAkSkmUiCAPTktvZ/ED8r6P61j5u8lXnbGeNU7a7vivgWF4CaF4 +a4m4oAVHUxWLKmeSLN8CeIn6AZSmqc5e/0DMEMGuzqC/XBKWz55bhkzq/CAMdPcI +1zbFcXkUM7tWJzLbgAW4R3Bpe1GUD8+h0fG9v8xnJOf1vQ1M4veJ1OQrEhFwuI4+ +TQIDAQAB +-----END PUBLIC KEY----- diff --git a/crypto-inventory/test/fixturi/negative/Comentarii.java b/crypto-inventory/test/fixturi/negative/Comentarii.java new file mode 100644 index 0000000..18dfce0 --- /dev/null +++ b/crypto-inventory/test/fixturi/negative/Comentarii.java @@ -0,0 +1,14 @@ +// MessageDigest.getInstance("MD5") intr-un comentariu nu e o folosire. +package fixturi; + +public class Comentarii { + /* Cipher.getInstance("DES") intr-un comentariu bloc */ + /** + * Signature.getInstance("SHA1withRSA") in Javadoc. + */ + private final String doc = "KeyPairGenerator.getInstance(\"RSA\") in a string"; + + public String doc() { + return doc; + } +} diff --git a/crypto-inventory/test/fixturi/negative/comentarii.go b/crypto-inventory/test/fixturi/negative/comentarii.go new file mode 100644 index 0000000..9fdfdf6 --- /dev/null +++ b/crypto-inventory/test/fixturi/negative/comentarii.go @@ -0,0 +1,11 @@ +// Fixtura negativa Go: niciun import criptografic. +package fixturi + +/* +import "crypto/md5" +*/ + +// md5.Sum(data) intr-un comentariu nu e o folosire. +func Nimic() string { + return "crypto/rsa rsa.GenerateKey(rand.Reader, 1024)" +} diff --git a/crypto-inventory/test/fixturi/negative/comentarii.js b/crypto-inventory/test/fixturi/negative/comentarii.js new file mode 100644 index 0000000..971a747 --- /dev/null +++ b/crypto-inventory/test/fixturi/negative/comentarii.js @@ -0,0 +1,14 @@ +// Fixtura negativa: fisierul importa node:crypto doar pentru UUID-uri. Comentariile pomenesc +// API-uri reale; niciunul nu e o folosire si scanerul trebuie sa dea ZERO gasiri. +import crypto from 'node:crypto'; + +// crypto.createHash('md5') intr-un comentariu nu e o folosire. +// const k = crypto.generateKeyPairSync('rsa', { modulusLength: 1024 }); +/* crypto.createCipheriv('des-ede3-cbc', key, iv) intr-un comentariu bloc */ +/** + * Exemplu din documentatie: crypto.createSign('RSA-SHA1'). + * RSA, ECDSA, secp256k1 si X25519 sunt aici doar cuvinte. + */ +export function id() { + return crypto.randomUUID(); // crypto.createHmac('sha1', k) tot comentariu +} diff --git a/crypto-inventory/test/fixturi/negative/docstring.py b/crypto-inventory/test/fixturi/negative/docstring.py new file mode 100644 index 0000000..b47bc4a --- /dev/null +++ b/crypto-inventory/test/fixturi/negative/docstring.py @@ -0,0 +1,13 @@ +"""Modul fara criptografie. + +Documentatia pomeneste rsa.generate_private_key(public_exponent=65537, key_size=1024) +si hashlib.md5(data), dar nu le cheama. +""" +import hashlib + + +def f(x): + """Returneaza x. Nu foloseste hashlib.sha1(x).""" + # hashlib.md5(x) intr-un comentariu nu e o folosire + text = "hashlib.md5 apare aici doar ca text" + return x, text, hashlib diff --git a/crypto-inventory/test/fixturi/negative/fara-cripto.js b/crypto-inventory/test/fixturi/negative/fara-cripto.js new file mode 100644 index 0000000..902927d --- /dev/null +++ b/crypto-inventory/test/fixturi/negative/fara-cripto.js @@ -0,0 +1,9 @@ +export function sign(x) { + return Math.sign(x); +} + +export function createHash(n) { + return { n, hash: n * 31 }; +} + +export const rezultat = [sign(-3), createHash(7)].map((v) => JSON.stringify(v)); diff --git a/crypto-inventory/test/fixturi/negative/siruri.js b/crypto-inventory/test/fixturi/negative/siruri.js new file mode 100644 index 0000000..fdefe30 --- /dev/null +++ b/crypto-inventory/test/fixturi/negative/siruri.js @@ -0,0 +1,9 @@ +import crypto from 'node:crypto'; + +export const doc = "Call crypto.createHash('md5') to hash (documentation string)."; +export const tpl = `Use crypto.createCipheriv('des-ede3-cbc', key, iv) at ${Date.now()}`; +export const eticheta = 'crypto.generateKeyPairSync("rsa", { modulusLength: 1024 })'; +export const re = /createHash\('sha1'\)/; +export function id() { + return crypto.randomUUID(); +} diff --git a/crypto-inventory/test/fixturi/negative/variabile.js b/crypto-inventory/test/fixturi/negative/variabile.js new file mode 100644 index 0000000..2644817 --- /dev/null +++ b/crypto-inventory/test/fixturi/negative/variabile.js @@ -0,0 +1,12 @@ +// Fixtura: algoritmi dati prin variabile care NU se pot rezolva static. Trebuie "unknown", nu ghicit. +import crypto from 'node:crypto'; + +let alg = 'md5'; +alg = 'sha256'; + +export function h(data, algDinApel, opts) { + const a = crypto.createHash(alg); // EXPECT: createHash | unknown + const b = crypto.createHash(algDinApel); // EXPECT: createHash | unknown + const c = crypto.createCipheriv(opts.cipher, opts.key, opts.iv); // EXPECT: createCipheriv | unknown + return [a, b, c, data]; +} diff --git a/crypto-inventory/test/fixturi/python/crypto_py.py b/crypto-inventory/test/fixturi/python/crypto_py.py new file mode 100644 index 0000000..cc626bf --- /dev/null +++ b/crypto-inventory/test/fixturi/python/crypto_py.py @@ -0,0 +1,32 @@ +"""Fixtura: criptografie in Python. Fisierul nu se executa; e doar text pentru scaner.""" +import hashlib +import hmac +import ssl +from cryptography.hazmat.primitives.asymmetric import rsa, ec, ed25519, padding +from cryptography.hazmat.primitives import hashes +from Crypto.Cipher import AES, DES3 +from ecdsa import SigningKey, SECP256k1 +import jwt +import oqs + +DIGEST = "sha256" + + +def exemple(data, key, alg_din_parametru): + k1 = rsa.generate_private_key(public_exponent=65537, key_size=1024) # EXPECT: RSA-1024 | weak-now + k2 = ec.generate_private_key(ec.SECP384R1()) # EXPECT: EC-secp384r1 | quantum-vulnerable + k3 = ed25519.Ed25519PrivateKey.generate() # EXPECT: Ed25519 | quantum-vulnerable + sig = k2.sign(data, ec.ECDSA(hashes.SHA256())) # EXPECT: ECDSA | quantum-vulnerable ; SHA-256 | quantum-safe + ct = k1.public_key().encrypt(data, padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None)) # EXPECT: RSA | quantum-vulnerable ; SHA-256 | quantum-safe + h1 = hashlib.md5(data).hexdigest() # EXPECT: MD5 | weak-now + h2 = hashlib.new(DIGEST) # EXPECT: SHA-256 | quantum-safe + h3 = hashlib.new(alg_din_parametru) # EXPECT: hashlib.new | unknown + mac = hmac.new(key, data, digestmod="sha1") # EXPECT: HMAC-SHA-1 | weak-now + c1 = AES.new(key, AES.MODE_ECB) # EXPECT: AES-ECB | weak-now + c2 = DES3.new(key, DES3.MODE_CBC) # EXPECT: 3DES-CBC | weak-now + sk = SigningKey.generate(curve=SECP256k1) # EXPECT: ECDSA-secp256k1 | quantum-vulnerable + tok = jwt.encode({"a": 1}, key, algorithm="ES256K") # EXPECT: JWS ES256K | quantum-vulnerable + kem = oqs.KeyEncapsulation("ML-KEM-768") # EXPECT: ML-KEM-768 | quantum-safe + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # EXPECT: TLS (negotiated) | unknown + ctx.minimum_version = ssl.TLSVersion.TLSv1_2 # EXPECT: TLSv1.2 | quantum-vulnerable + return [k3, sig, ct, h1, h2, h3, mac, c1, c2, sk, tok, kem] diff --git a/crypto-inventory/test/proba-timp.mjs b/crypto-inventory/test/proba-timp.mjs new file mode 100644 index 0000000..c040d2f --- /dev/null +++ b/crypto-inventory/test/proba-timp.mjs @@ -0,0 +1,72 @@ +// Proba de cost: inventarul trebuie sa ramana LINIAR pe fisiere facute anume (2026-09-29, revizuirea adversariala). Formele vechi +// ale cautarii PEM, ale literalului regex JS, ale docstring-ului Python, ale rezolvarii variabilelor si ale variabilei atribuite +// in Java costau patratic: un singur fisier de 1 MB (marimea maxima implicita) tinea scanarea minute intregi. +// Fiecare caz se scaneaza la doua marimi, a doua de 4 ori prima (64 si 256 KB; PEM 256 KB si 1 MB), fiecare intr-un proces +// separat cu termen; liniar inseamna ~4x, patratic ~16x. Verdict pe caz: VERDE daca t2 <= 6 * t1 + 250 ms si t2 < 10 s; ROSU +// altfel (si la depasirea termenului). +// node test/proba-timp.mjs [--doar caz1,caz2] -> 0 toate verzi, 1 unul rosu, 2 STRICAT (o masurare nu a pornit) +// Modulul masurat se schimba cu CRYPTO_INVENTORY_MODULE=/inventar.mjs (asa il foloseste controlul negativ). +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; + +const AICI = dirname(fileURLToPath(import.meta.url)); +const CALE_MODUL = process.env.CRYPTO_INVENTORY_MODULE ? resolve(process.env.CRYPTO_INVENTORY_MODULE) : join(AICI, '..', 'inventar.mjs'); +const TERMEN_MS = 20000; + +// unitatea se repeta pana la marime; '@' se inlocuieste cu un contor (nume distincte) +export const CAZURI = { + // pem: cautarea sfarsitului e nativa (indexOf), deci forma patratica se vede abia spre marimea maxima implicita + 'pem.txt': ['-----BEGIN CERTIFICATE-----AAAA\n', 256, 1024], + 'py-triplu.py': 'x="""a"""+', + 'py-linie.py': 'hashlib.new(x)+', + 'js-regex.js': '=/[', + 'js-linie.js': 'crypto.createHash(x)+', + 'js-func.js': 'crypto.createHash(q)\nfunction (', + 'java-linie.java': 'Cipher.getInstance(x)+', + 'java-kpg.java': 'k = KeyPairGenerator.getInstance("EC")+', +}; + +const marimi = (nume) => (Array.isArray(CAZURI[nume]) ? CAZURI[nume].slice(1) : [64, 256]); +const unitate = (nume) => (Array.isArray(CAZURI[nume]) ? CAZURI[nume][0] : CAZURI[nume]); +const genereaza = (unit, kb) => unit.repeat(Math.max(1, Math.floor((kb * 1024) / unit.length))); + +const arg = (f) => { const i = process.argv.indexOf(f); return i >= 0 ? process.argv[i + 1] : null; }; + +if (arg('--caz')) { + // copilul: scaneaza un singur fisier si tipareste milisecundele + const nume = arg('--caz'); const kb = Number(arg('--kb')); + const inv = await import(pathToFileURL(CALE_MODUL).href); + const d = mkdtempSync(join(tmpdir(), 'ci-timp-')); + try { + writeFileSync(join(d, nume), genereaza(unitate(nume), kb)); + const t0 = process.hrtime.bigint(); + const rez = inv.scan(d); + const ms = Number(process.hrtime.bigint() - t0) / 1e6; + if (rez.stats.filesSeen !== 1 || rez.stats.codeFilesTotal + rez.stats.textFilesPemOnly !== 1) throw new Error('fisierul nu a fost citit'); + console.log(JSON.stringify({ ms })); + } finally { rmSync(d, { recursive: true, force: true }); } +} else { + const doar = arg('--doar') ? arg('--doar').split(',') : Object.keys(CAZURI); + const masoara = (nume, kb) => { + const r = spawnSync(process.execPath, [fileURLToPath(import.meta.url), '--caz', nume, '--kb', String(kb)], { encoding: 'utf8', timeout: TERMEN_MS, env: process.env }); + if (r.error && r.error.code === 'ETIMEDOUT') return { termen: true }; + try { return { ms: JSON.parse((r.stdout || '').trim().split('\n').pop()).ms }; } catch { return { stricat: `cod ${r.status}: ${(r.stderr || '').trim().split('\n').slice(-1)[0]}` }; } + }; + let rosii = 0, stricate = 0; + for (const nume of doar) { + if (!CAZURI[nume]) { stricate++; console.log(`STRICAT ${nume}: caz necunoscut`); continue; } + const [k1, k2] = marimi(nume); + const a = masoara(nume, k1); + const b = a.ms !== undefined ? masoara(nume, k2) : a.termen ? {} : { stricat: `nemasurat (${k1} KB a cazut)` }; + if (a.stricat || b.stricat) { stricate++; console.log(`STRICAT ${nume}: ${a.stricat || b.stricat}`); continue; } + if (a.termen || b.termen) { rosii++; console.log(`ROSU ${nume}: peste ${TERMEN_MS / 1000} s la ${a.termen ? k1 : k2} KB`); continue; } + const ok = b.ms <= 6 * a.ms + 250 && b.ms < 10000; + if (!ok) rosii++; + console.log(`${ok ? 'VERDE ' : 'ROSU '} ${nume.padEnd(16)} ${String(k1).padStart(4)} KB ${a.ms.toFixed(0).padStart(6)} ms | ${String(k2).padStart(4)} KB ${b.ms.toFixed(0).padStart(6)} ms x${(b.ms / Math.max(a.ms, 1)).toFixed(1)}`); + } + console.log(`\ncostul inventarului: ${doar.length - rosii - stricate}/${doar.length} liniare, ${rosii} rosii, ${stricate} stricate (modul: ${CALE_MODUL})`); + process.exitCode = stricate ? 2 : rosii ? 1 : 0; +} diff --git a/crypto-inventory/test/proba.mjs b/crypto-inventory/test/proba.mjs new file mode 100644 index 0000000..27aed24 --- /dev/null +++ b/crypto-inventory/test/proba.mjs @@ -0,0 +1,486 @@ +// Suita de probe a inventarului criptografic. +// Folosire: node test/proba.mjs [--json] +// Modulul masurat se poate schimba cu CRYPTO_INVENTORY_MODULE=/inventar.mjs (asa il foloseste +// control-negativ.mjs pe o copie stricata). Fixturile raman mereu cele din test/fixturi. +// Fiecare proba ruleaza izolat: o proba care arunca iese ROSU cu mesajul ei, restul ruleaza mai departe. +import { readFileSync, readdirSync, statSync, existsSync, mkdtempSync, writeFileSync, rmSync, copyFileSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { generateKeyPairSync } from 'node:crypto'; + +const AICI = dirname(fileURLToPath(import.meta.url)); +const FIX = join(AICI, 'fixturi'); +const CALE_MODUL = process.env.CRYPTO_INVENTORY_MODULE ? resolve(process.env.CRYPTO_INVENTORY_MODULE) : join(AICI, '..', 'inventar.mjs'); +const inv = await import(pathToFileURL(CALE_MODUL).href); +const CA_JSON = process.argv.includes('--json'); + +// numarul total de marcaje EXPECT scrise de mana in fixturi; o schimbare aici se face constient +const MARCAJE_ASTEPTATE = 96; +const FISIERE_CU_MARCAJE = ['js/node-crypto.mjs', 'js/webcrypto.js', 'js/biblioteci.ts', 'js/tls.cjs', 'python/crypto_py.py', 'java/Crypto.java', 'go/crypto.go', 'negative/variabile.js']; + +const probe = []; +const proba = (nume, fn) => probe.push({ nume, fn }); +function asigura(cond, mesaj) { if (!cond) throw new Error(mesaj); } + +const memo = new Map(); +function scan(dir, optiuni = {}) { + const k = dir + JSON.stringify(optiuni); + if (!memo.has(k)) { + try { memo.set(k, { rez: inv.scan(dir, optiuni) }); } catch (err) { memo.set(k, { err }); } + } + const r = memo.get(k); + if (r.err) throw r.err; + return r.rez; +} + +function marcaje(rel) { + const r = []; + readFileSync(join(FIX, rel), 'utf8').split('\n').forEach((l, i) => { + const m = /EXPECT:\s*(.+?)\s*$/.exec(l); + if (!m) return; + for (const p of m[1].split(' ; ')) { + const [n, c] = p.split(' | ').map((s) => s.trim()); + r.push(`${i + 1}|${n}|${c}`); + } + }); + return r.sort(); +} + +function gasiriDin(rez, rel) { + return rez.findings.filter((g) => g.file === rel); +} + +function comparaExact(rel) { + const rez = scan(FIX); + const ast = marcaje(rel); + asigura(ast.length > 0, `${rel}: fixtura nu are marcaje`); + const real = gasiriDin(rez, rel).map((g) => `${g.line}|${g.name}|${g.classification}`).sort(); + const lipsa = ast.filter((x) => !real.includes(x)); + const extra = real.filter((x) => !ast.includes(x)); + asigura(!lipsa.length && !extra.length && ast.length === real.length, + `${rel}: lipsa [${lipsa.join(', ')}] extra [${extra.join(', ')}] (asteptate ${ast.length}, gasite ${real.length})`); +} + +// --- pozitive: fiecare gasire asteptata, cu linia si clasa ei, si nimic in plus --- +for (const rel of FISIERE_CU_MARCAJE.filter((f) => !f.startsWith('negative/'))) { + proba(`pozitiv: ${rel}`, () => comparaExact(rel)); +} + +proba('pozitiv: numarul total de marcaje EXPECT este cel scris', () => { + const n = FISIERE_CU_MARCAJE.reduce((s, f) => s + marcaje(f).length, 0); + asigura(n === MARCAJE_ASTEPTATE, `marcaje citite ${n}, scrise ${MARCAJE_ASTEPTATE}`); + const rez = scan(FIX); + const inFisiere = rez.findings.filter((g) => FISIERE_CU_MARCAJE.includes(g.file)).length; + asigura(inFisiere === MARCAJE_ASTEPTATE, `gasiri in fisierele cu marcaje: ${inFisiere}, marcaje ${MARCAJE_ASTEPTATE}`); +}); + +proba('pozitiv: variabila rezolvata la un literal unic poarta resolvedFrom', () => { + const g = gasiriDin(scan(FIX), 'js/node-crypto.mjs').find((x) => x.name === 'SHA-256'); + asigura(g && /HASH_ALG \(line 6\)/.test(g.resolvedFrom || ''), `resolvedFrom: ${g && g.resolvedFrom}`); + const j = gasiriDin(scan(FIX), 'java/Crypto.java').find((x) => x.name === 'SHA-256'); + asigura(j && /DIGEST/.test(j.resolvedFrom || ''), `java resolvedFrom: ${j && j.resolvedFrom}`); +}); + +proba('pozitiv: manifestele dau biblioteci declarate, nu gasiri', () => { + const rez = scan(join(FIX, 'manifeste')); + asigura(rez.findings.length === 0, `gasiri din manifeste: ${rez.findings.length}`); + const libs = rez.libraries.map((b) => `${b.file}:${b.line} ${b.name}`).sort(); + const ast = ['go.mod:6 github.com/cloudflare/circl', 'go.mod:7 golang.org/x/crypto', 'package.json:10 @noble/post-quantum', + 'package.json:5 ethers', 'package.json:7 jsonwebtoken', 'pom.xml:5 org.bouncycastle:bcprov-jdk18on', + 'requirements.txt:2 cryptography', 'requirements.txt:3 pyjwt'].sort(); + asigura(JSON.stringify(libs) === JSON.stringify(ast), `biblioteci: ${libs.join(' | ')}`); + const bom = inv.buildCbom(rez, { deterministic: true }); + const c = bom.components.filter((x) => x.type === 'library'); + asigura(c.length === 8 && c.every((x) => x.properties.some((p) => p.name.endsWith('declared-only') && p.value === 'true')), 'componentele library nu poarta declared-only'); +}); + +proba('pozitiv: material PEM (certificat si cheie publica)', () => { + const rez = scan(FIX); + const c = gasiriDin(rez, 'material/certificat-proba.pem'); + asigura(c.length === 1 && c[0].assetType === 'certificate' && c[0].name === 'EC-secp256r1' && c[0].classification === 'quantum-vulnerable' && c[0].line === 1, `certificat: ${JSON.stringify(c.map((x) => [x.assetType, x.name, x.line]))}`); + asigura(/crypto-inventory-fixture/.test(c[0].certificate.subjectName), 'subiectul certificatului lipseste'); + const p = gasiriDin(rez, 'material/cheie-publica-proba.pem'); + asigura(p.length === 1 && p[0].assetType === 'related-crypto-material' && p[0].material.type === 'public-key' && p[0].name === 'RSA-2048' && p[0].line === 2, `cheie publica: ${JSON.stringify(p.map((x) => [x.assetType, x.name, x.line]))}`); +}); + +proba('pozitiv: cheie privata generata la rulare e raportata, iar valoarea ei nu apare in iesire', () => { + const d = mkdtempSync(join(tmpdir(), 'ci-cheie-')); + try { + const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + writeFileSync(join(d, 'config.txt'), `setari\n${pem}`); + const rez = inv.scan(d); + asigura(rez.findings.length === 1, `gasiri: ${rez.findings.length}`); + const g = rez.findings[0]; + asigura(g.assetType === 'related-crypto-material' && g.material.type === 'private-key' && g.name === 'EC-secp256r1' && g.line === 2, `gasirea: ${JSON.stringify([g.assetType, g.material, g.name, g.line])}`); + const corp = pem.split('\n').filter((l) => l && !l.startsWith('-----')).join(''); + const iesire = JSON.stringify(inv.buildCbom(rez)) + JSON.stringify(rez.findings) + inv.renderSummary(rez); + for (let i = 0; i + 24 <= corp.length; i += 8) asigura(!iesire.includes(corp.slice(i, i + 24)), `o bucata din cheia privata apare in iesire (offset ${i})`); + } finally { + rmSync(d, { recursive: true, force: true }); + } +}); + +// --- clasificare --- +proba('clasificare: secp256k1 este quantum-vulnerable, cu recomandare de cont post-cuantic', () => { + const a = inv.classify({ grup: 'SECP256K1' }); + asigura(a.classification === 'quantum-vulnerable' && a.quantumVulnerable === true, `clasa ${a.classification}`); + asigura(/post-quantum key/.test(a.recommendation) && /not a library swap/.test(a.recommendation), 'recomandarea nu vorbeste de contul post-cuantic'); + const f = gasiriDin(scan(FIX), 'js/node-crypto.mjs').find((x) => x.curve === 'secp256k1'); + asigura(f && f.classification === 'quantum-vulnerable', 'gasirea secp256k1 din fixtura nu e quantum-vulnerable'); +}); + +proba('clasificare: MD5 si SHA-1 sunt weak-now, SHA-256 quantum-safe', () => { + const c = (h) => inv.classify({ grup: 'HASH', hash: h }).classification; + asigura(c('MD5') === 'weak-now' && c('SHA1') === 'weak-now' && c('SHA256') === 'quantum-safe', `MD5 ${c('MD5')} SHA1 ${c('SHA1')} SHA256 ${c('SHA256')}`); +}); + +proba('clasificare: AES-128 quantum-safe cu nota Grover, ECB weak-now, DES weak-now', () => { + const a = inv.classify({ grup: 'CIPHER', nume: 'AES', param: '128', mod: 'gcm' }); + asigura(a.classification === 'quantum-safe' && /Grover/.test(a.reason + a.recommendation) && a.nistQuantumSecurityLevel === 1, `AES-128: ${a.classification} ${a.nistQuantumSecurityLevel}`); + asigura(inv.classify({ grup: 'CIPHER', nume: 'AES', param: '256', mod: 'ecb' }).classification === 'weak-now', 'ECB nu e weak-now'); + asigura(inv.classify({ grup: 'CIPHER', nume: 'DES' }).classification === 'weak-now', 'DES nu e weak-now'); + asigura(inv.classify({ grup: 'TLS', param: '1.1', rol: 'min' }).classification === 'weak-now', 'TLS 1.1 nu e weak-now'); +}); + +proba('clasificare: fiecare gasire vulnerabila sau slaba are motiv si recomandare', () => { + const rez = scan(FIX); + for (const g of rez.findings) { + asigura(g.reason && g.reason.length > 20, `${g.file}:${g.line} ${g.name} fara motiv`); + if (g.classification === 'quantum-vulnerable' || g.classification === 'weak-now' || g.classification === 'unknown') { + asigura(g.recommendation && g.recommendation.length > 20, `${g.file}:${g.line} ${g.name} (${g.classification}) fara recomandare`); + } + } +}); + +proba('clasificare: JWT asimetric spune ca standardul post-cuantic nu e final', () => { + const g = gasiriDin(scan(FIX), 'js/biblioteci.ts').find((x) => x.name === 'JWS RS256'); + asigura(g && /no final standard/.test(g.recommendation) && /draft/.test(g.recommendation), 'recomandarea JWT nu spune ca nu exista standard final'); +}); + +// --- negative --- +function controlPozitivMd5() { + const g = gasiriDin(scan(FIX), 'js/node-crypto.mjs').find((x) => x.name === 'MD5'); + asigura(g, 'controlul pozitiv al metodei: MD5 din js/node-crypto.mjs lipseste, deci un zero nu inseamna nimic'); +} + +proba('negativ: comentariile JS nu produc gasiri', () => { + controlPozitivMd5(); + const g = gasiriDin(scan(FIX), 'negative/comentarii.js'); + asigura(g.length === 0, `comentariile au produs: ${g.map((x) => `${x.line} ${x.name}`).join(', ')}`); +}); + +proba('negativ: apelul scris intr-un sir JS nu e folosire', () => { + controlPozitivMd5(); + const g = gasiriDin(scan(FIX), 'negative/siruri.js'); + asigura(g.length === 0, `sirurile au produs: ${g.map((x) => `${x.line} ${x.name}`).join(', ')}`); +}); + +proba('negativ: comentariile si docstring-urile Python nu produc gasiri', () => { + const rez = scan(FIX); + asigura(gasiriDin(rez, 'python/crypto_py.py').some((x) => x.name === 'MD5'), 'controlul pozitiv: hashlib.md5 din fixtura Python lipseste'); + const g = gasiriDin(rez, 'negative/docstring.py'); + asigura(g.length === 0, `docstring/comentariu au produs: ${g.map((x) => `${x.line} ${x.name}`).join(', ')}`); +}); + +proba('negativ: comentariile si sirurile Java si Go nu produc gasiri', () => { + const rez = scan(FIX); + asigura(gasiriDin(rez, 'java/Crypto.java').some((x) => x.name === 'MD5') && gasiriDin(rez, 'go/crypto.go').some((x) => x.name === 'MD5'), 'controlul pozitiv: MD5 din fixturile Java/Go lipseste'); + const g = [...gasiriDin(rez, 'negative/Comentarii.java'), ...gasiriDin(rez, 'negative/comentarii.go')]; + asigura(g.length === 0, `au produs: ${g.map((x) => `${x.file}:${x.line} ${x.name}`).join(', ')}`); +}); + +proba('negativ: fisier fara criptografie da zero gasiri, si a fost citit', () => { + const rez = scan(join(FIX, 'negative')); + asigura(rez.stats.filesSeen === 7 && rez.stats.codeFilesTotal === 7, `vazute ${rez.stats.filesSeen}, analizate ${rez.stats.codeFilesTotal}`); + const g = gasiriDin(rez, 'fara-cripto.js'); + asigura(g.length === 0, `fara-cripto.js a produs ${g.length}`); +}); + +proba('negativ: algoritm din variabila reasignata sau din parametru iese unknown, nu ghicit', () => { + comparaExact('negative/variabile.js'); + const g = gasiriDin(scan(FIX), 'negative/variabile.js'); + asigura(g.every((x) => x.classification === 'unknown' && !x.resolvedFrom), 'o variabila nerezolvabila a primit o valoare'); +}); + +proba('negativ: fisierul binar e sarit si numarat', () => { + const rez = scan(join(FIX, 'binar')); + asigura(rez.stats.filesSeen === 1 && rez.stats.notRead.binary === 1 && rez.findings.length === 0, `vazute ${rez.stats.filesSeen}, binare ${rez.stats.notRead.binary}, gasiri ${rez.findings.length}`); + asigura(rez.stats.notReadExamples.binary.includes('modul.js'), 'exemplul binar lipseste din statistica'); + // control pozitiv: aceiasi octeti fara NUL dau gasirea, deci zeroul de mai sus vine din saritura + const d = mkdtempSync(join(tmpdir(), 'ci-binar-')); + try { + const b = readFileSync(join(FIX, 'binar', 'modul.js')); + writeFileSync(join(d, 'modul.js'), Buffer.from([...b].filter((x) => x !== 0))); + const r2 = inv.scan(d); + asigura(r2.findings.some((x) => x.name === 'MD5'), 'controlul pozitiv: fara NUL, fisierul nu produce MD5'); + } finally { rmSync(d, { recursive: true, force: true }); } +}); + +proba('negativ: fisier prea mare sarit si numarat', () => { + const rez = scan(join(FIX, 'limite'), { maxFileBytes: 400 }); + asigura(rez.stats.filesSeen === 2 && rez.stats.notRead.tooLarge === 1, `vazute ${rez.stats.filesSeen}, prea mari ${rez.stats.notRead.tooLarge}`); + asigura(/^mare\.js \(\d+ bytes\)$/.test(rez.stats.notReadExamples.tooLarge[0] || ''), `exemplu: ${rez.stats.notReadExamples.tooLarge[0]}`); + asigura(!rez.findings.some((x) => x.file === 'mare.js') && rez.findings.some((x) => x.file === 'mic.js'), 'gasirile nu corespund limitei'); + const tot = scan(join(FIX, 'limite')); + asigura(tot.findings.some((x) => x.file === 'mare.js' && x.name === 'MD5'), 'controlul pozitiv: fara limita, mare.js nu da MD5'); +}); + +proba('negativ: limita de fisiere numara restul', () => { + const rez = scan(join(FIX, 'limite'), { maxFiles: 1 }); + asigura(rez.stats.filesSeen === 2 && rez.stats.notRead.fileLimit === 1 && rez.stats.codeFilesTotal === 1, `vazute ${rez.stats.filesSeen}, peste limita ${rez.stats.notRead.fileLimit}`); +}); + +// --- cazurile-limita ale formelor liniare (2026-09-29, revizuirea adversariala: cautarea PEM, literalul regex JS, docstring-ul +// Python si rezolvarea variabilelor aveau cost patratic; formele noi trebuie sa dea EXACT ce dadeau cele vechi, si aici stau +// cazurile pe care o forma liniara scrisa gresit le-ar schimba). Modulele se iau de langa modulul masurat (copia controlului). +const LIB = join(dirname(CALE_MODUL), 'lib'); +const lexer = await import(pathToFileURL(join(LIB, 'lexer.mjs')).href); +const pem = await import(pathToFileURL(join(LIB, 'pem.mjs')).href); +const contextMod = await import(pathToFileURL(join(LIB, 'context.mjs')).href); + +proba('limite: un bloc PEM care incepe in liniutele unui antet fara sfarsit e gasit', () => { + const corp = 'A'.repeat(64); + const t = `-----BEGIN PRIVATE KEY-----BEGIN CERTIFICATE-----${corp}-----END CERTIFICATE-----`; + const b = [...pem.blocuriPem(t)].map((x) => `${x.index}|${x.tip}|${x.corp}`); + asigura(JSON.stringify(b) === JSON.stringify([`22|CERTIFICATE|${corp}`]), `blocuri: ${JSON.stringify(b)}`); + const fara = [...pem.blocuriPem(`-----BEGIN CERTIFICATE-----${corp}."-----END CERTIFICATE-----`)]; + asigura(fara.length === 0, 'un caracter din afara alfabetului corpului inainte de sfarsit trebuie sa anuleze blocul'); +}); + +proba('limite: literalul regex JS se cauta din starea lui, nu din ce a esuat alt literal pe acelasi rand', () => { + // primul `/` deschide o clasa care nu se inchide (esec pana la capatul randului, trecand prin 8 IN clasa); al doilea porneste + // din 8 IN AFARA clasei si se inchide la 10 + const r = lexer.curata('x =/[ =/ x/;', 'javascript'); + asigura(JSON.stringify(r.siruri) === JSON.stringify([[7, 11]]), `siruri: ${JSON.stringify(r.siruri)}`); +}); + +proba('limite: docstring Python pe randul lui (si dupa un sir neterminat), dar nu dupa cod pe acelasi rand', () => { + // docstring-ul de dupa sirul neterminat e INDENTAT: spatiile de inainte de el sunt pe randul nou, nu in coada sirului + const t = "a = 'abc\n '''doc'''\ny = '''s'''\n r'''d2'''\n"; + const r = lexer.curata(t, 'python'); + asigura(!r.code.includes('doc') && !r.code.includes('d2'), `docstring-urile trebuiau golite: ${JSON.stringify(r.code)}`); + asigura(r.code.includes("'''s'''"), `sirul triplu de dupa cod trebuia pastrat: ${JSON.stringify(r.code)}`); + const s = t.indexOf("'''s'''"); + asigura(r.siruri.some(([a, b]) => a === s && b === s + 7), `intervalul sirului s lipseste: ${JSON.stringify(r.siruri)}`); +}); + +proba('limite: peste plafonul de variabile pe fisier numele ies unknown si fisierul se numara', () => { + const d = mkdtempSync(join(tmpdir(), 'ci-plafon-')); + try { + const n = contextMod.REZOLVARI_PE_FISIER; + const linii = ["import { createHash } from 'node:crypto';"]; + for (let i = 0; i <= n; i++) linii.push(`const h${i} = 'sha256';`, `createHash(h${i});`); + writeFileSync(join(d, 'multe.mjs'), linii.join('\n') + '\n'); + const rez = inv.scan(d); + const rezolvate = rez.findings.filter((g) => g.name === 'SHA-256' && g.resolvedFrom).length; + const necunoscute = rez.findings.filter((g) => g.classification === 'unknown').length; + asigura(rezolvate === n && necunoscute === 1, `rezolvate ${rezolvate} (cerut ${n}), unknown ${necunoscute} (cerut 1)`); + asigura(rez.stats.resolutionLimitFiles === 1 && rez.stats.resolutionLimitExamples[0] === 'multe.mjs', `statistica: ${rez.stats.resolutionLimitFiles}`); + } finally { rmSync(d, { recursive: true, force: true }); } +}); + +// --- CBOM --- +const E = { + bom: ['$schema', 'bomFormat', 'specVersion', 'serialNumber', 'version', 'metadata', 'components', 'services', 'externalReferences', 'dependencies', 'compositions', 'properties', 'vulnerabilities', 'annotations', 'formulation', 'declarations', 'definitions', 'signature'], + component: ['type', 'mime-type', 'bom-ref', 'supplier', 'manufacturer', 'authors', 'author', 'publisher', 'group', 'name', 'version', 'description', 'scope', 'hashes', 'licenses', 'copyright', 'cpe', 'purl', 'omniborId', 'swhid', 'swid', 'modified', 'pedigree', 'externalReferences', 'properties', 'components', 'evidence', 'releaseNotes', 'modelCard', 'data', 'cryptoProperties', 'signature', 'tags'], + componentType: ['application', 'framework', 'library', 'container', 'platform', 'operating-system', 'device', 'device-driver', 'firmware', 'file', 'machine-learning-model', 'data', 'cryptographic-asset'], + cryptoProperties: ['assetType', 'algorithmProperties', 'certificateProperties', 'relatedCryptoMaterialProperties', 'protocolProperties', 'oid'], + assetType: ['algorithm', 'certificate', 'protocol', 'related-crypto-material'], + algorithmProperties: ['primitive', 'parameterSetIdentifier', 'curve', 'executionEnvironment', 'implementationPlatform', 'certificationLevel', 'mode', 'padding', 'cryptoFunctions', 'classicalSecurityLevel', 'nistQuantumSecurityLevel'], + primitive: ['drbg', 'mac', 'block-cipher', 'stream-cipher', 'signature', 'hash', 'pke', 'xof', 'kdf', 'key-agree', 'kem', 'ae', 'combiner', 'other', 'unknown'], + mode: ['cbc', 'ecb', 'ccm', 'gcm', 'cfb', 'ofb', 'ctr', 'other', 'unknown'], + padding: ['pkcs5', 'pkcs7', 'pkcs1v15', 'oaep', 'raw', 'other', 'unknown'], + cryptoFunctions: ['generate', 'keygen', 'encrypt', 'decrypt', 'digest', 'tag', 'keyderive', 'sign', 'verify', 'encapsulate', 'decapsulate', 'other', 'unknown'], + protocolProperties: ['type', 'version', 'cipherSuites', 'ikev2TransformTypes', 'cryptoRefArray'], + protocolType: ['tls', 'ssh', 'ipsec', 'ike', 'sstp', 'wpa', 'other', 'unknown'], + certificateProperties: ['subjectName', 'issuerName', 'notValidBefore', 'notValidAfter', 'signatureAlgorithmRef', 'subjectPublicKeyRef', 'certificateFormat', 'certificateExtension'], + relatedCryptoMaterialProperties: ['type', 'id', 'state', 'algorithmRef', 'creationDate', 'activationDate', 'updateDate', 'expirationDate', 'value', 'size', 'format', 'securedBy'], + materialType: ['private-key', 'public-key', 'secret-key', 'key', 'ciphertext', 'signature', 'digest', 'initialization-vector', 'nonce', 'seed', 'salt', 'shared-secret', 'tag', 'additional-data', 'password', 'credential', 'token', 'other', 'unknown'], + evidence: ['identity', 'occurrences', 'callstack', 'licenses', 'copyright'], + occurrence: ['bom-ref', 'location', 'line', 'offset', 'symbol', 'additionalContext'], +}; + +export function valideazaCbom(bom) { + const err = []; + const e = (m) => err.push(m); + const chei = (o, perm, unde) => { for (const k of Object.keys(o)) if (!perm.includes(k)) e(`${unde}: cheie nepermisa "${k}"`); }; + const sir = (v, unde) => { if (typeof v !== 'string' || !v.length) e(`${unde}: trebuie sir nevid`); }; + const dataIso = (v, unde) => { if (typeof v !== 'string' || Number.isNaN(Date.parse(v)) || !/^\d{4}-\d{2}-\d{2}T/.test(v)) e(`${unde}: nu e date-time`); }; + chei(bom, E.bom, 'bom'); + if (bom.bomFormat !== 'CycloneDX') e('bomFormat'); + if (bom.specVersion !== '1.6') e('specVersion'); + if (!/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(bom.serialNumber || '')) e(`serialNumber ${bom.serialNumber}`); + if (!Number.isInteger(bom.version) || bom.version < 1) e('version'); + const md = bom.metadata || {}; + if (md.timestamp !== undefined) dataIso(md.timestamp, 'metadata.timestamp'); + if (!md.tools || !Array.isArray(md.tools.components) || !md.tools.components.length) e('metadata.tools.components'); + else for (const t of md.tools.components) { sir(t.name, 'tool.name'); sir(t.version, 'tool.version'); if (!E.componentType.includes(t.type)) e('tool.type'); } + if (!Array.isArray(bom.components)) { e('components nu e lista'); return err; } + const refs = new Set(); + for (const [i, c] of bom.components.entries()) { + const u = `components[${i}]`; + chei(c, E.component, u); + if (!E.componentType.includes(c.type)) e(`${u}.type ${c.type}`); + sir(c.name, `${u}.name`); + sir(c['bom-ref'], `${u}.bom-ref`); + if (refs.has(c['bom-ref'])) e(`${u}: bom-ref dublat ${c['bom-ref']}`); + refs.add(c['bom-ref']); + if (c.properties) for (const p of c.properties) { sir(p.name, `${u}.properties.name`); if (typeof p.value !== 'string') e(`${u}.properties.value`); } + if (c.type === 'cryptographic-asset') { + const cp = c.cryptoProperties; + if (!cp) { e(`${u}: cryptoProperties lipsa`); continue; } + chei(cp, E.cryptoProperties, `${u}.cryptoProperties`); + if (!E.assetType.includes(cp.assetType)) e(`${u}.assetType ${cp.assetType}`); + if (cp.oid !== undefined && !/^[0-2](\.\d+)+$/.test(cp.oid)) e(`${u}.oid ${cp.oid}`); + if (cp.assetType === 'algorithm') { + const ap = cp.algorithmProperties; + if (!ap) { e(`${u}: algorithmProperties lipsa`); continue; } + chei(ap, E.algorithmProperties, `${u}.algorithmProperties`); + if (!E.primitive.includes(ap.primitive)) e(`${u}.primitive ${ap.primitive}`); + if (ap.parameterSetIdentifier !== undefined) sir(ap.parameterSetIdentifier, `${u}.parameterSetIdentifier`); + if (ap.curve !== undefined) sir(ap.curve, `${u}.curve`); + if (ap.mode !== undefined && !E.mode.includes(ap.mode)) e(`${u}.mode ${ap.mode}`); + if (ap.padding !== undefined && !E.padding.includes(ap.padding)) e(`${u}.padding ${ap.padding}`); + if (ap.cryptoFunctions !== undefined && (!Array.isArray(ap.cryptoFunctions) || ap.cryptoFunctions.some((f) => !E.cryptoFunctions.includes(f)))) e(`${u}.cryptoFunctions`); + if (ap.classicalSecurityLevel !== undefined && (!Number.isInteger(ap.classicalSecurityLevel) || ap.classicalSecurityLevel < 0)) e(`${u}.classicalSecurityLevel`); + if (ap.nistQuantumSecurityLevel !== undefined && (!Number.isInteger(ap.nistQuantumSecurityLevel) || ap.nistQuantumSecurityLevel < 0 || ap.nistQuantumSecurityLevel > 6)) e(`${u}.nistQuantumSecurityLevel`); + } else if (cp.assetType === 'protocol') { + const pp = cp.protocolProperties || {}; + chei(pp, E.protocolProperties, `${u}.protocolProperties`); + if (!E.protocolType.includes(pp.type)) e(`${u}.protocolProperties.type`); + if (pp.version !== undefined) sir(pp.version, `${u}.protocolProperties.version`); + } else if (cp.assetType === 'certificate') { + const cc = cp.certificateProperties || {}; + chei(cc, E.certificateProperties, `${u}.certificateProperties`); + for (const k of ['notValidBefore', 'notValidAfter']) if (cc[k] !== undefined) dataIso(cc[k], `${u}.${k}`); + } else if (cp.assetType === 'related-crypto-material') { + const rm = cp.relatedCryptoMaterialProperties || {}; + chei(rm, E.relatedCryptoMaterialProperties, `${u}.relatedCryptoMaterialProperties`); + if (!E.materialType.includes(rm.type)) e(`${u}.relatedCryptoMaterialProperties.type`); + if (rm.size !== undefined && !Number.isInteger(rm.size)) e(`${u}.size`); + if (rm.value !== undefined) e(`${u}: valoarea materialului nu are voie sa apara`); + } + } + if (!c.evidence || !Array.isArray(c.evidence.occurrences) || !c.evidence.occurrences.length) { e(`${u}: evidence.occurrences lipsa`); continue; } + chei(c.evidence, E.evidence, `${u}.evidence`); + for (const o of c.evidence.occurrences) { + chei(o, E.occurrence, `${u}.occurrence`); + sir(o.location, `${u}.occurrence.location`); + if (o.line !== undefined && (!Number.isInteger(o.line) || o.line < 1)) e(`${u}.occurrence.line`); + } + } + return err; +} + +proba('cbom: structura CycloneDX 1.6 (campuri obligatorii, tipuri, enumerari)', () => { + const rez = scan(FIX); + for (const det of [false, true]) { + const bom = inv.buildCbom(rez, { deterministic: det }); + const err = valideazaCbom(bom); + asigura(err.length === 0, `erori (${det ? 'determinist' : 'normal'}): ${err.slice(0, 5).join('; ')}`); + asigura(det ? bom.metadata.timestamp === undefined : typeof bom.metadata.timestamp === 'string', 'timestamp'); + } + const bom = inv.buildCbom(rez); + const tipuri = new Set(bom.components.map((c) => (c.cryptoProperties ? c.cryptoProperties.assetType : c.type))); + for (const t of ['algorithm', 'protocol', 'certificate', 'related-crypto-material', 'library']) asigura(tipuri.has(t), `lipseste tipul ${t}`); + // controlul negativ al validatorului: o forma stricata trebuie sa fie refuzata + const rau = JSON.parse(JSON.stringify(bom)); + rau.components[0].cryptoProperties = { assetType: 'algorithm', algorithmProperties: { primitive: 'magic' } }; + rau.components[1]['bom-ref'] = rau.components[2]['bom-ref']; + rau.specVersion = '1.5'; + asigura(valideazaCbom(rau).length >= 3, 'validatorul nu refuza un CBOM stricat'); +}); + +proba('cbom: bom-ref unice si stabile intre doua rulari', () => { + const a = inv.buildCbom(inv.scan(FIX), { deterministic: true }); + const b = inv.buildCbom(inv.scan(FIX), { deterministic: true }); + const ra = a.components.map((c) => c['bom-ref']); + asigura(new Set(ra).size === ra.length, `bom-ref dublate: ${ra.length - new Set(ra).size}`); + asigura(JSON.stringify(a) === JSON.stringify(b), 'doua rulari deterministe difera'); + const gasiri = inv.scan(FIX).findings.length; + const occ = a.components.filter((c) => c.type === 'cryptographic-asset').reduce((s, c) => s + c.evidence.occurrences.length, 0); + asigura(occ === gasiri, `occurrences ${occ} != gasiri ${gasiri}`); +}); + +proba('cbom: fiecare occurrence arata un fisier care exista, cu o linie din fisier', () => { + const bom = inv.buildCbom(scan(FIX)); + let n = 0; + for (const c of bom.components) { + for (const o of c.evidence.occurrences) { + const f = join(FIX, o.location); + asigura(existsSync(f) && statSync(f).isFile(), `nu exista: ${o.location}`); + const linii = readFileSync(f, 'utf8').split('\n').length; + asigura(o.line >= 1 && o.line <= linii, `${o.location}: linia ${o.line} din ${linii}`); + n++; + } + } + asigura(n > 50, `prea putine occurrences: ${n}`); +}); + +// --- CLI --- +function cli(args) { + return spawnSync(process.execPath, [CALE_MODUL, ...args], { encoding: 'utf8', timeout: 60000 }); +} + +proba('cli: --fail-on vulnerable iese 1 pe cod vulnerabil si 0 pe cod fara criptografie', () => { + const r = cli(['scan', join(FIX, 'js'), '--fail-on', 'vulnerable', '--summary']); + asigura(r.status === 1 && /quantum-vulnerable finding/.test(r.stderr), `status ${r.status}, stderr ${r.stderr.slice(0, 200)}`); + const d = mkdtempSync(join(tmpdir(), 'ci-cli-')); + try { + copyFileSync(join(FIX, 'negative', 'fara-cripto.js'), join(d, 'fara-cripto.js')); + const r0 = cli(['scan', d, '--fail-on', 'vulnerable', '--summary']); + asigura(r0.status === 0, `fara criptografie: status ${r0.status}`); + } finally { rmSync(d, { recursive: true, force: true }); } + const r2 = cli(['scan', join(FIX, 'js'), '--fail-on', 'nimic']); + asigura(r2.status === 2, `valoare --fail-on invalida: status ${r2.status}`); + const r3 = cli(['scan', join(FIX, 'js'), '--fail-on', 'weak', '--summary']); + asigura(r3.status === 1 && /weak-now finding/.test(r3.stderr), `--fail-on weak: status ${r3.status}`); +}); + +proba('cli: --summary spune cate fisiere nu au fost citite si de ce', () => { + const r = cli(['scan', FIX, '--summary', '--max-file-bytes', '400']); + asigura(r.status === 0, `status ${r.status} ${r.stderr}`); + const m = /Not read: (\d+) binary, (\d+) too large \(> 400 bytes\), (\d+) over the file limit/.exec(r.stdout); + asigura(m && m[1] === '1' && Number(m[2]) >= 1 && m[3] === '0', `randul "Not read": ${m ? m[0] : 'lipsa'}`); + asigura(/e\.g\. binary: binar\/modul\.js/.test(r.stdout), 'exemplul binar lipseste din rezumat'); + const rj = cli(['scan', join(FIX, 'go'), '--deterministic']); + const bom = JSON.parse(rj.stdout); + asigura(bom.specVersion === '1.6' && valideazaCbom(bom).length === 0, 'CBOM-ul de pe stdout nu e valid'); +}); + +proba('rezumat: caracterele de control din numele de fisiere nu ajung in terminal', () => { + const rez = scan(FIX); + const i = rez.findings.findIndex((g) => g.classification === 'quantum-vulnerable'); + asigura(i >= 0, 'controlul pozitiv: fixturile trebuie sa aiba o gasire vulnerabila'); + const rau = 'x\u001b]0;titlu\u0007\u001b[31mrosu.js'; + const t = inv.renderSummary({ ...rez, findings: rez.findings.map((g, k) => (k === i ? { ...g, file: rau } : g)) }); + asigura(t.includes('x?]0;titlu??[31mrosu.js'), 'numele fisierului trebuia sa apara, cu caracterele de control inlocuite'); + asigura(!/[\u0000-\u0009\u000b-\u001f\u007f]/.test(t), 'un caracter de control a ajuns in rezumat'); +}); + +proba('rezumat: numaratoarea pe clase egala cu gasirile', () => { + const rez = scan(FIX); + const c = inv.countByClass(rez.findings); + const s = Object.values(c).reduce((a, b) => a + b, 0); + asigura(s === rez.findings.length, `suma claselor ${s}, gasiri ${rez.findings.length}`); + const t = inv.renderSummary(rez); + for (const [k, v] of Object.entries(c)) asigura(new RegExp(`${k}\\s+${v}\\b`).test(t), `rezumatul nu arata ${k} ${v}`); +}); + +// --- rularea --- +const rezultate = {}; +const erori = {}; +for (const p of probe) { + try { await p.fn(); rezultate[p.nume] = 'VERDE'; } catch (err) { rezultate[p.nume] = 'ROSU'; erori[p.nume] = String(err && err.message || err); } +} +const verzi = Object.values(rezultate).filter((x) => x === 'VERDE').length; +if (CA_JSON) { + process.stdout.write(JSON.stringify({ suita: 'crypto-inventory', modul: CALE_MODUL, rulate: probe.length, verzi, rosii: probe.length - verzi, rezultate, erori }) + '\n'); +} else { + for (const p of probe) process.stdout.write(`${rezultate[p.nume].padEnd(6)} ${p.nume}${erori[p.nume] ? `\n ${erori[p.nume]}` : ''}\n`); + process.stdout.write(`\n${verzi} din ${probe.length} probe verzi (modul: ${CALE_MODUL})\n`); +} +process.exitCode = verzi === probe.length ? 0 : 1; diff --git a/pq-kms/README.md b/pq-kms/README.md index c88b1c4..0dc5e82 100644 --- a/pq-kms/README.md +++ b/pq-kms/README.md @@ -45,6 +45,10 @@ the root. Set exactly one of `AERE_KMS_ROOT_KEY` / `AERE_KMS_ROOT_HSM`; both is AERE_HSM_PIN=... node hsm-radacina.mjs sigileaza --modul /usr/lib/softhsm/libsofthsm2.so --token aere-kms --id 0a --iesire root-hsm.json --nou AERE_KMS_ROOT_HSM=root-hsm.json AERE_HSM_MODULE=/usr/lib/softhsm/libsofthsm2.so AERE_HSM_PIN=... AERE_KMS_TOKEN=... node server.mjs +The tool's command words are Romanian: `sigileaza` seals (`--modul` the PKCS#11 library, `--iesire` the output file, never +overwritten, `--nou` a new random root instead of `AERE_KMS_ROOT_KEY`), and `verifica ` checks that a sealed root opens +with this HSM and PIN (it also needs `AERE_HSM_MODULE`). It prints `ok:` or `refused: : `. + The sealed file does not choose what the process loads or sends: the PKCS#11 library comes from the process configuration (`AERE_HSM_MODULE`, an absolute path) and must be exactly the one recorded in the file, checked before any HSM tool runs (`HSM_MODULE_NOT_ALLOWED` otherwise); the PIN is always read from `AERE_HSM_PIN`, and a diff --git a/pq-kms/hsm-radacina.mjs b/pq-kms/hsm-radacina.mjs index c045121..f46f9f2 100644 --- a/pq-kms/hsm-radacina.mjs +++ b/pq-kms/hsm-radacina.mjs @@ -135,8 +135,8 @@ async function cli(argv) { console.log(`sealed root written to ${iesire} (${FORMAT_HSM}, token ${sig.token}, key id ${sig.idCheie})`); } else if (cmd === 'verifica') { const r = deschideRadacina(JSON.parse(fs.readFileSync(rest[0], 'utf8'))); r.fill(0); - console.log('DA: the sealed root opens with this HSM and PIN'); + console.log('ok: the sealed root opens with this HSM and PIN'); } else { console.error('usage: node hsm-radacina.mjs sigileaza --modul --token