All notable changes to QRAMM CryptoDeps will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
1.3.0 - 2026-07-30
Fixes both open community issues, and the release-blocking defects a fresh-user release test then found while reproducing them. An earlier 1.3.0 candidate carrying only the community fixes was prepared on 2026-07-27 but never tagged, so everything below ships together in this release.
-
requirements-*.txtfiles were skipped (#1). Manifest discovery matched the exact filenamerequirements.txt, so therequirements-dev.txtandrequirements-prod.txtsplit that most Python projects use was never scanned. Therequirements*.txtfamily and therequirements/*.txtdirectory layout are now discovered and parsed. -
CBOM did not identify which dependency an algorithm came from (#2). Output emitted only the algorithm, carrying the dependency's version but never its name, so a component reading
{"name": "RSA", "version": "1.3.2"}was unattributable. Each dependency is now emitted as its ownlibrarycomponent with abom-ref, and the CycloneDXdependenciesgraph links each algorithm to the library that provides it. -
pyproject.tomlandPipfileproduced fabricated dependencies. Both were advertised as supported, but parsing fell through to therequirements.txtline parser behind aTODO. Apyproject.tomlyielded entries named after TOML keys (name,dependencies,requires-python), and because the real packages were never identified, a project depending oncryptographyreported "No cryptographic usage detected". Both formats now have real TOML parsers covering PEP 621, Poetry, and Pipfile layouts. -
Every CBOM shared one serial number.
generateUUIDreturned a hardcoded all-zero UUID, which also failed the CycloneDXurn:uuidpattern. Serial numbers are now random version 4 UUIDs. -
Unresolved build properties were emitted as versions. A Maven dependency declared as
${java-jwt.version}appeared in the CBOM with that literal as its version. Version ranges such as>=2.0were likewise emitted where CycloneDX expects a concrete version. Only pinned versions are now reported as versions; the declared constraint is preserved in the component description. -
CBOM
primitivevalues were outside the CycloneDX enum, which failed schema validation for the whole document. Categories now map onto the permitted enum, resolvingencryptionby algorithm where possible. -
Every machine-readable output reported the wrong tool version. One binary gave four answers:
versionsaid 1.3.0 while SARIF and CBOM both claimed 1.0.0 and JSON carried no version at all. SARIF and CBOM are provenance artifacts, so a stale literal there is a false record of what produced the document. A newpkg/versionpackage is now the single source of truth, fed once from the values GoReleaser injects intomain. SARIF also gainedsemanticVersion, and JSON gained a top-leveltoolobject. -
A manifest that could not be parsed was dropped silently and the scan still reported a clean summary. A tree containing a good and a corrupt
package.jsonscanned only the good one and never mentioned the other, so a manifest broken by a bad merge became invisible and CI went green. The cause was not the parse-error path: discovery rejected the file before any parser ran. Unreadable manifests are now listed by name with the reason in the table, JSON, markdown and SARIF output, and always exit 2. A tree whose only manifest is corrupt no longer claims that no manifest was found. -
A scan where every dependency was unknown reported "No cryptographic usage detected". Nothing had been examined. The three cases (no dependencies, all dependencies unknown, and dependencies analyzed with no findings) are now worded differently, and the
--deephints that the analyzer had always generated are finally printed. -
--deepresults were then ignored by that same verdict, and the report sent the user back to--deep. Found while release-testing this version, and a regression against 1.2.2 rather than a pre-existing defect:analyze <tree> --deep, where the database covers none of the dependencies and source analysis reads all of them, printed "Not analyzed. All 3 dependencies are absent from the crypto database, so no conclusion about cryptographic usage can be drawn from this scan. Run with--deep". Every clause of that was false for the run that produced it, the JSON for the same invocation reporteddeepAnalyzedon all three, and 1.2.2 printed a correct clean verdict. The fix above asked whether the database covered a package in order to answer whether anything had examined it, which were the same question until--deepbecame a second way to examine one. Coverage is now counted where examination happens, assummary.notExamined, and every format reads it. Reports also distinguish "not examined because you did not ask for source analysis", where the next step is--deep, from "not examined because the package could not be fetched", where it is not;summary.deepAttemptedrecords which. -
A cached package that had never been unpacked was counted as analyzed. The npm and PyPI fetchers returned one directory after extracting an archive and a different one on a cache hit, and the cache-hit check asked only whether the version directory existed. A directory holding nothing but the downloaded tarball answers yes, so its contents were walked, no source file was found, and the package was recorded as read by source analysis with no findings and no warning. The two paths disagreed for real packages too: an npm tarball that does not unpack to
package/, asejsdoes not, failed on the first run and was silently analyzed from the wrong root on every run after it, so the same project reported 43 packages analyzed with a warning and then 44 without one. Both paths now resolve the extracted source through one helper, and a cache entry that holds none is refetched rather than accepted. The Maven fetcher accepted anextracted/directory that a failed unzip had left empty; that is covered by the same check. -
A cache entry the fetcher could not identify was deleted rather than reported. Where an entry held more than one candidate directory, the resolver could not say which was the package and returned an error saying so. All three cache-hit sites discarded that error and cleared the entry, so a cached tree that may well have held the findings was destroyed by a scan that had only failed to identify it, the refetch loop was unbounded, and the diagnostic written for the case was unreachable: the user was shown whatever the refetch failed with, typically
npm pack failed. Such an entry is now kept and the reason reported. -
A deep scan of a package with no readable source was counted as an examination.
--deepmarked a dependency analyzed whenever the fetch and the walk both returned without an error, and a walk over a tree holding no file the analyzer understands returns nothing rather than an error. A Maven artifact whose sources JAR does not exist falls back to the main JAR, which carries compiled classes only, so no.javafile was ever read and the report still saidNo cryptographic usage detected in the 1 of 1 dependencies that were examined. The walkers now report how many files they parsed, the count travels with the analysis asanalysis.filesAnalyzed, and the claim of an examination is made from it. A package whose archive holds nothing readable is reported as not examined, with the reason on stderr and in theerrorfield of the document, so a consumer can tell it from a package that was skipped. 1.2.2 answers the same scan with a plain clean verdict, so this is not a regression; the wording introduced in this release asserted an examination that had not happened.The count is of files parsed, not files opened, which is a distinction the first version of this fix did not make. Three of the four analyzers are line scanners whose only failure was
os.Open, so any openable file with a matching extension counted: a zero-byteEmpty.java, or compiled class bytes under a.javaname, each producedfilesAnalyzed: 1and restored the exact verdict above. An extension is a descriptor rather than the thing itself, so a file now counts only if it holds text the analyzer can read."Text the analyzer can read" then had to be corrected twice more. It first meant "the head is valid UTF-8", which accepted every NUL-free binary over the head size, because the allowance for a rune straddling the boundary shrank the head one byte at a time and
utf8.Validreports true for an empty slice: 2000 bytes of0xffcounted as a parsed file. Bounding that allowance then left the UTF-8 requirement itself, which was never the right question. These analyzers are line scanners matching ASCII identifiers, so they neither need nor check valid UTF-8, and requiring it refused legitimate source in any single-byte encoding: a.javaor.jsfile holding one Latin-1 accent in a comment was reported as unreadable and the cryptography beside the accent was never looked for, where 1.2.2 had found it. A file is now text if it holds no NUL byte and its head is either valid UTF-8 or predominantly printable ASCII, which is the property a line scanner actually depends on. The threshold is measured: across 140 real npm source files carrying non-ASCII characters, re-encoded to Latin-1, the lowest printable-ASCII fraction was 0.904 and the median 0.999, while random NUL-free bytes reached at most 0.453 and real binaries with their NUL bytes stripped reached 0.286. -
A source file the analyzer refused was dropped silently, and the package was still reported as examined. The count of failed files was incremented inside the directory walk and went no further, so a dependency holding one file that parsed and one that did not was described as examined and clean, on every stream and in every format, with zero bytes on stderr. Combined with the encoding defect above it made an evasion rather than an inconvenience: a dependency could hide its cryptography from this scanner by encoding the file that calls MD5 in Latin-1, and the report said the package had been examined and no cryptography was detected. The earlier coverage fix does not reach this state, because it asks its question of a dependency nothing examined, and a dependency read in part is counted as examined. The count now travels with the analysis as
analysis.filesUnreadable, sums into the summary assourceFilesUnreadable, is named on stderr per package, and reaches all five formats through the one classifier they share. -
Source analysis of a PyPI dependency could execute code the scanned manifest chose.
npm packruns with--ignore-scriptsin this release, which closes that half of the class; pip had no equivalent. Resolving a PyPI name and version with no matching wheel makes pip install the project's build dependencies and run its build backend, which for asetup.pysdist is arbitrary code on the scanning host, from a package named by a manifest the operator did not write. Verified directly:pip3 download --no-deps psycopg2==2.9.9reports "Installing build dependencies" and "Preparing metadata". Fetching source in order to read it must not be a way to run it, so the fetch is now restricted to built wheels with--only-binary=:all:. Pre-existing rather than a regression; it is fixed here because the release would otherwise state that this class was closed while half of it was open. The cost is recorded under known limitations. -
The npm name grammar refused 1,054 real, installable packages. Introduced by this release. Holding a name to its registry's grammar was the right layer, and the pattern encoded the rules npm applies to a name from a NEW publisher: a scope and a name each beginning with a letter or a digit. npm grandfathered every name that predates those rules. Swept against the complete registry (4,240,864 names), the pattern refused 1,054 that are published and installable now, nine of them above 100,000 downloads a month, including
@lingo.dev/_specat 212,000,@-xun/fs,@_sh/strapi-plugin-ckeditorand@~39/empty. A refused dependency is silently never analyzed, so the scan reports a coverage it never had, which is this release's own false-clean defect arrived at from the other direction. The grammar now excludes what lets a name be read as something other than a name, which is the question a fetch guard exists to answer: an at sign separates a name from a spec, a slash or a backslash makes it a path, a colon makes it a scheme, and whitespace separates arguments. A leading dot is refused separately, so that loosening the grammar cannot quietly admit it. The same sweep over the other three ecosystems refuses nothing real: 0 of 860,284 PyPI names, 0 of 16,279 Go module paths, 0 of 1,800 Maven coordinates. -
A Maven property in a dependency's groupId or artifactId was never resolved. Properties were expanded in the version alone, so
${project.groupId}, which is how a multi-module build names a sibling module, survived into the coordinate and was then refused as a name that could steer a fetch. Across 2,099 real poms from Maven Central, 81 coordinates in 16 published artifacts use it. On one real pom the tool analyzed 6 of its dependencies where the same pom with the property expanded analyzed 11. Properties are now resolved in all three fields, and the project's owngroupIdandartifactIdare available as properties alongside its version. -
A manifest-declared version traversed out of the source cache behind any scheme. The screen that refuses a local path skipped its traversal check whenever the value contained a colon, on the reasoning that a colon meant a remote reference such as
github:owner/repo. An invented scheme defeats that: a version ofa1:../../../../../../../victimcarries a colon, so the check never ran,npm packresolved a directory outside the cache, and the analyzer read it and published its absolute paths as that dependency's source.Reproduced against the candidate binary:
npm packresolves such a spec as a directory, consuming two../per level, and at eight the target sat two levels above the cache root. The scan exited 1 having copied the victim's source into the cache and published itsSECRET.jsas MD5 and RSA findings attributed to the declared dependency, with zero bytes on stderr. Present in 1.2.2, where it additionally ran the target'spreparescript, so the fourth spelling of this class was until now closed only in its execution half. The traversal check now runs on every value: no legitimate version carries a..path element, and whether a value is remote is decided by the scheme rather than by the presence of a punctuation mark. -
A failed archive extraction was reported as an examined package on the next run. The three download sites discard what a failed fetch left behind; the four extraction sites returned a bare error instead. Both
tarandunzipextract partially before failing, so the cache entry was left holding a populated directory, which the cache-hit check accepts. The first scan reported the package as not examined and the second reported it as examined, with findings, from a partially extracted archive, with nothing on any stream saying so.unzipexits non-zero on warnings alone, so a benign real wheel could take this path too. All four sites now discard the entry. -
A file with one line longer than 64 KiB contributed nothing, silently.
bufio.Scanner's default token limit stops the scan and reports an error, the three line-scanning analyzers return it, and the walk discards every usage they had already found. Bundled and minified output is normally one very long line, so adist/*.jsof 70 KB on one line callingcrypto.createHash('md5')produced no finding, no warning and a clean verdict, on this candidate and on 1.2.2. The scanner is now given the same bound the file already has, and the per-file cap moves from 8 MiB to 32 MiB:aws-sdk-gov1.55.5 shipsservice/ec2/api.goat 7,771,273 bytes, within 8 percent of the old cap, and that file has grown every release. This does not reach a file named*.min.js, which the JavaScript walker skips by name before the analyzer sees it; see known limitations. What the larger cap costs was measured: a 30 MiB single-line file dense with cryptographic calls peaks at about 309 MB of resident memory and takes 2.4 seconds. Files are read one at a time, so that is the bound for a scan rather than a per-archive total. -
A Poetry or Pipfile dependency declared by location was fetched from PyPI under its bare name. An inline table with no
versionkey returned no version at all, discarding thepath,git,urlorfileit was actually declared by, and the fetcher then downloaded whatever PyPI serves under that name. Sointernal-lib = {path = "../internal-lib"}was replaced by a public package of the same name, whose source was analyzed and reported as this project's. That is dependency confusion performed by the scanner: registering the name of an organisation's local package is enough to be handed the attribution, and becausepipbuilds sdists, execution with it. New in this release, which is where these TOML parsers were added. The locator is now kept so the fetcher's own guards see what was declared. -
--fail-onwas validated one way and read another. The validation added in this release trims and lowercases before deciding; the code that turns the value into an exit code only lowercased. So" partial "passed validation, matched no policy, and fell through to the default vulnerable-only gate: a project whose findings are partial risk exited 3 forpartialand 0 for" partial ", with nothing on either stream. Whitespace around a value is the ordinary result of a YAML block scalar or a workflow expression, which is exactly where this flag is used, and the new validation is what made the padded value look accepted. Both readers now canonicalise through one function. -
A symlink in an extracted archive was followed out of the source cache. The directory walk lstats, so a symlink is not a directory and fell through to the file analyzer, which opened whatever it pointed at. Both
tarandunziprestore absolute symlink targets, so a package could shipLeak.javapointing at any file the scanning process can read and have this tool read it, count it as evidence that the package was examined, and publish its contents as that dependency's source in JSON, SARIF and CBOM. Symlinks are no longer read: an extracted package is analyzed from its own contents. -
CBOM and SARIF omitted the dependencies a scan could not examine, whenever it had findings for the others. The coverage question was asked only of reports that produced nothing, and a partially examined project produces something. So a Maven scan that told the operator on three separate streams that it could not read one of its dependencies handed GitHub code scanning a SARIF run reporting success with no notification, and produced a CBOM listing the libraries it had findings for with no coverage property. The two formats that omitted it are the two that get uploaded. Incomplete coverage is now reported as a property of the scan, in the same place a withheld-findings filter already was, so it reaches every format whether or not findings were also produced.
-
--fail-onaccepted any value and silently loosened the gate. It was the one enum flag of the three that did not validate, and the only one that decides an exit code: a project exiting 3 under--fail-on partialexited 0 under--fail-on partail, with nothing on either stream, because an unrecognised value fell through to the default policy. It now rejects an unknown value, naming the legal ones.It rejects an empty value too, which
--riskand--min-severitydeliberately still accept, so this is not the parity an earlier draft of this entry claimed. For those two, blank means "do not filter", which is a real state they can express.--fail-onalready has a default ofvulnerableand cannot express one by being blank, so a blank value there was indistinguishable from an unset flag while silently selecting the vulnerable-only policy: a partial-risk project exited 3 forpartialand 0 for"". An unset workflow input is exactly how a CI gate arrives here empty. This is a behaviour change for anyone forwarding a possibly-unset input:--fail-on ""and--fail-on " "now exit 2 without scanning, where they previously ran a full scan under the vulnerable policy. It fails closed. The bundled Action is unaffected, since it defaults the input tovulnerable. -
pyproject.tomlandPipfilekept the comparison operator in the version. The same two packages gavepycryptodome@3.20.0throughrequirements.txtandpycryptodome@==3.20.0through the other two formats, and within one scan the CBOM purl normalised it while JSON and SARIF did not, so a consumer could not join the two documents by version. It also reached the fetcher, which builds a pip spec asname==version. New in 1.3.0, alongside the parsers that made those formats work at all. -
--riskand--min-severitydid nothing. Both were stored and never read, so every value, including a misspelt one, produced byte-identical output. They now filter, and the summary is computed from what survives so that every number in the report describes the same set of findings. Unknown values are rejected instead of ignored. When a filter removes every finding, the report says so rather than reporting a clean scan. -
A reporting filter could then open the CI gate, which was a regression introduced by making those filters work. Found in this release's own final review. The exit code was computed from the same filtered summary as the report, so
analyze . --fail-on vulnerableexited 1 andanalyze . --fail-on vulnerable --risk safeexited 0 on the same project, with nine vulnerable findings withheld. The same run printed "This is not a clean result. Re-run without the filter to see them." to stdout: the tool told the operator it had hidden findings and told CI the project was clean.--min-severityreaches it the same way, which matters more in practice, because--min-severity high --fail-on vulnerableis a plausible CI line that silently stops failing on anything below HIGH. Released 1.2.2 exits 1 for all of these, because the filters did nothing at all there, so this was a regression in the one flag that decides CI outcomes, in the release that hardened that flag three separate times against silent loosening.A view flag does not answer a gate. The scan now records what the filters withheld, broken down by the risk levels
--fail-onasks about, and the gate reads found-not-shown alongside shown.--fail-on nonestill means none, an unfiltered clean project still exits 0, and no output format changes: the withheld breakdown is deliberately not serialized. The two copies of the exit-code switch, one for a single project and one for a workspace, are now one function with two callers, since carrying the same logic twice is how they could disagree in the first place. -
Every SARIF result pointed at a literal path
"multiple". Multi-project runs flattened all projects into one synthetic result, discarding the real manifest paths, so every alert landed on a file that does not exist. Results now carry their own project's manifest, relative to the scan root and declared throughSRCROOTinoriginalUriBaseIds. -
Output order shuffled between runs of the same scan.
package.jsondependency blocks were read by ranging over maps, and findings were sorted on risk alone with a non-stable sort. The finding set was stable but the order was not, which breaks golden-file CI and reproducible SBOMs. Discovery, parsing and rendering are now fully ordered, so ordering is stable across runs in all five formats. The JSON scan timestamp and the CBOM serial number necessarily differ between runs; every other byte is identical. -
A repository containing an unsupported manifest type always exited 2. Discovery recognised
Cargo.toml,Gemfile,composer.jsonand the Gradle files, but no parser exists for any of them, so each became a reported skip, and a skip forces exit 2. A tree with ago.modbeside aCargo.tomlreported an analysis error instead of the exit 1 its real quantum-vulnerable findings had earned, so the CI signal the tool exists to emit was replaced by an error about a file cryptodeps never claimed to read. Such files are now reported as an unsupported ecosystem rather than an unread manifest, and only a manifest that should have been readable and was not marks the scan incomplete. -
A filtered scan reported clean in every format except the table. The verdict that distinguishes "nothing was found" from "nothing was examined" from "everything was withheld" reached the table only. Markdown still printed "No cryptographic usage detected in dependencies.", SARIF asserted
executionSuccessfulover an empty result set, and CBOM emitted no components and said nothing, so a consumer of any of them read a clean bill of health. All five formats now classify through one shared function, SARIF records coverage astoolExecutionNotifications, and CBOM records it asmetadata.properties. -
The aggregate summary of a workspace scan omitted the withheld count.
AggregateResultssummed nine fields and notfilteredOut, sototalSummary.filteredOutstayed absent while the per-project summaries reported dozens. The totals a reader actually looks at described a filtered scan as a complete one. -
--min-severitydiscarded findings whose severity was not upper case. Severity ranking is keyed by the upper-case constants and a Go map returns zero for an absent key, so an unrecognised severity ranked asINFOand any higher threshold dropped it, uncounted. Database records arrive from a remote feed with no normalisation, so a record carryingcriticalwas discarded by the very filter a user reaches for to see critical findings. Ranking is now case-insensitive, and a severity that cannot be ranked is reported rather than withheld. -
analyze <manifest-file>emitted SARIF pointing at nothing. Passing a file rather than a directory made theSRCROOTbase the manifest itself, so every result resolved to the literal".". The base is now the containing directory. -
The markdown remediation table shuffled between runs. It was the one format still ranging over a map after the determinism work, so ten runs of the same scan produced ten different documents.
-
The GitHub Action published zero counts and could not upload SARIF. It read
.summaryfrom JSON, but workspace discovery is the default and a multi-project document carries.totalSummary, sovulnerable-countwas always 0. Its SARIF step also treated any non-zero exit as a step failure, which skipped the upload for exactly the incomplete scans most worth reporting. -
A CBOM published the operator's filesystem layout. Manifest paths were rendered relative to the scan root so that a shared bill of materials carries the repository layout and not a home directory or a CI runner's workspace path, but the comparison used the scan root exactly as it was typed while the manifest paths had already been absolutized.
cryptodeps analyze /abs/pathproduced relative paths andcryptodeps analyze ., which is the default and what the Action runs, emitted absolute ones. SARIF had always normalized the root; both formats now share one implementation, so they cannot disagree again. -
A scanned repository could write its own lines into the report. Every filesystem path reached the table and markdown reports uninterpolated, and a path is attacker-controlled: a directory named with embedded newlines and the text
## Scan result: CLEANput exactly that heading in the markdown report, above the corrupt manifest the report exists to disclose. A directory nameda|bshifted a column out of the "Not analyzed" table, because GitHub-flavoured markdown splits a cell on an unescaped pipe even inside a code span. Every path, skip reason and dependency string in the markdown report is now rendered inside a code span, where only a backtick and a pipe are active, rather than escaped character by character: the first attempt at this escaped control characters and left a bare##heading, so a directory named**CLEAN**or[no findings](https://...)still rendered as markup. In the plain-text report, and inside the code spans, anything carrying a control character, a Unicode line or paragraph separator, a bidi override, a zero-width character, a backtick or a pipe is rendered as a quoted string: single-line, reversible, and still naming the file. JSON, CBOM and SARIF were never affected by the line-injection vector, becauseencoding/jsonescapes what it emits. -
A dependency string could write its own lines into the report too. The path fix did not cover the other channel into the same document: a dependency name and version come from the manifest under scan, and both were interpolated bare into the markdown findings tables and the table report. It needs no filesystem access at all, which makes it easier to reach than the directory name that was fixed first: the database lookup falls back from "name@version" to the name alone, so a real package with a version of "1.3.1\n\n## Scan result: CLEAN" still resolved, reached the findings table, and put that heading in the report seven times. Now rendered through the same code spans every path uses. Present in 1.2.2 as well; the fix is not a regression repair.
-
The table and markdown reports named manifests differently from the CBOM and SARIF for the same scan. Only the two machine-readable formats expressed a manifest relative to the scan root; the table used a string-prefix test that compared the root as typed against absolutized paths, and markdown did not relativize at all. Every surface of one run now names a manifest the same way, with one absolute anchor per document.
getRelativePathalso returned a path that does not exist when the root was a string prefix of a sibling directory, so("/repo", "/repository/go.mod")gave./sitory/go.mod. -
cryptodeps statusreported roughly twice the packages the database holds. On a 901-record database it announced 1731, and every per-ecosystem number was wrong the same way. The index files each package under two keys,name@versionandname, so a lookup succeeds with or without a version, and the count was of index entries rather than packages.statusnow reports the record count itself, matching the database's own stats block and a direct count of its records, and it lists the ecosystems in a fixed order instead of the order the map happened to iterate in.
-
A scanned manifest could point
--deepat any directory the process could read. The source cache path was built from the dependency name and version exactly as declared, so apackage.jsoncarrying{"ejs": "../../../somewhere"}resolved the cache entry outside the cache directory. That directory existed, the cache-hit check accepted it, and the analyzer walked it and published its absolute file paths and line numbers as that dependency's source. Scanning an untrusted repository in CI could therefore put the contents of an unrelated directory into the SARIF the run uploads. Present in 1.2.2 and in every earlier release with--deep. Manifest-supplied names and versions are now reduced to a single safe path segment before they are joined to the cache path, and a name or a version that names a local path is refused with a message saying so rather than passed tonpm packorpip download, which would resolve it. Registry and VCS references such asgithub:owner/repoare unaffected. -
The same class reached the dependency name, not only the version, and through the name it reached code execution. Reducing the name to a safe path segment kept the cache entry in place, which made the name look handled, but the name is also given to the package manager as a spec, and
npm pack ../../../../victimresolves a directory. A manifest declaring{"../../../../victim": ""}packed a tree outside the project, walked it and published its file names and line numbers.Screening the name for the spellings of a local path did not close this, because the package manager parses a string the scanner treated as opaque.
npm packsplitsname@specon the first@after index 0, so a dependency namedx@/path/to/victimpresented a passing string to the guard and a directory to npm. Reproduced end to end: npm packed the victim directory, ran itsprepareandprepackscripts on the scanning host, and the analyzer published the victim's files as that dependency's findings, with nothing on stderr. The relative form needs no knowledge of absolute paths. The same shape reachespip downloadas a PEP 508 direct reference, which arrives as a Poetry or Pipfile table key (victimpkg @ file:///path), andgit+file://reachednpm packthrough the version, since it neither begins withfile:nor contains a..segment.Names are now held to their own registry's grammar (npm's scope-and-name form, PEP 503 for PyPI, module-path form for Go, and Maven coordinates as before), which is a question with a single answer rather than a list of the ways a path can be spelled.
npm packadditionally runs with--ignore-scriptsandpip downloadwith--only-binary=:all:, so neither the remote VCS references that remain fetchable nor a source distribution can execute anything during a fetch. The suite asserts that real npm, PyPI and Go names pass the grammars, including scoped npm names and versioned Go module paths. Two limits on that claim, both stated because an earlier draft of this entry overstated it: the assertion is that a grammar accepts a name, which is not the same as a completed fetch, and a dotted PyPI name reaches the grammar intact only throughpyproject.toml. Declared inrequirements.txt,zope.interfaceis split at the first dot by the requirements parser long before the grammar sees it, which is a separate pre-existing defect, recorded under known limitations below. An earlier draft of this sentence said it was recorded there when it was not. -
A scanned
pom.xmlcould write any file the scanning process could write. This one is not a read.fetchMavenArtifactjoined theartifactIdfrom the manifest straight onto the download path handed tocurl -o, andfilepath.Joinresolves..lexically, so the target escaped the cache. A?in theartifactIdsplit the Maven URL so that its path portion still named a real artifact while the file portion traversed, which supplies the 200 thatcurl -fneeds in order to write at all. Reproduced against 1.2.2 and against the 1.3.0 candidate: a 26-byte file outside the cache was replaced with 234540 bytes of an unrelated archive by oneanalyze --deep. The attacker chooses the path; the content is any artifact on Maven Central. Coordinates are now validated as coordinates, which closes the URL and the path at once, and the file name the fetcher writes is derived from sanitized segments regardless.Present in 1.2.2 and in every earlier release with
--deep, alongside the read above. All three require the operator to run--deepover a manifest they do not control, which is what a CI scan of an untrusted repository does. -
The local-path guard did not cover the Windows spellings. A colon was read as evidence that a reference was remote, so a drive letter passed:
C:\victim,\victimand\\host\sharereached the package manager as folder specs on a target this tool ships binaries for. They are now refused with the other local paths, together with the Yarn and pnpmlink:andportal:spellings, whichnpm packrejects today and which should not depend on another tool's parser staying strict. Maven coordinates and npm aliases, which legitimately carry a colon, are unaffected. -
The source cache could delete more than the entry it meant to. The only destructive operation in the fetcher took whatever path it was handed, and its argument is built from manifest-supplied text. Nothing stated its bounds: four separate mutations of it, up to and including removing the entire cache root, left the test suite passing. It now refuses anything that is not exactly one package entry inside the cache directory, and the failed-download paths route through the same guard. No input was found that reached the wider deletion, since the segments are sanitized before they are joined; this bounds the operation rather than closing a known route to it.
-
output.PrintSkippedtakes the scan root as its second argument, so it can name a manifest the same way the rest of the report does. This is a breaking change to an exported function in an importable package. -
Coloured emoji in the table output are replaced by the ASCII markers the section headers already use:
[!]vulnerable,[~]partial,[OK]safe,[?]unknown. They need no legend, and unlike the emoji they survive a pipe into a file, a terminal without an emoji font, and a screen reader. This also brings the tool in line with the CSNP no-emoji standard. -
A runtime failure no longer prints the full flag list after the error. The message that explains the failure was being pushed off the top of the terminal. Usage is still shown for genuine flag mistakes, where it helps.
-
The root help no longer advertises "Full dependency tree analysis", and the README no longer claims transitive coverage. Neither was true: only declared dependencies are read. See known limitations.
-
Regression tests for manifest discovery, the three Python formats, PEP 508 requirement parsing, CBOM dependency attribution, version resolution, serial number uniqueness, and primitive enum conformance.
-
analysis.filesAnalyzedon every deep-analyzed record, anderroron any dependency source analysis could not examine, in JSON output. A document that claims a package was read now carries the count it was claimed from, and one that skips a package says why. -
analysis.filesUnreadableper dependency andsummary.sourceFilesUnreadableper project, in JSON output, with a warning on stderr naming each package and a coverage note in the table, markdown, SARIF and CBOM. The evidence for an examination now carries its exceptions as well as its count, so a partial reading cannot be read as a complete one. -
summary.notExaminedandsummary.deepAttemptedin JSON output, and a Not Examined row in the markdown summary table. How much of a tree a scan actually covered was previously only derivable, and only wrongly, from the count of packages missing from the database. -
Regression tests for the coverage verdict driven end to end through a real
--deepscan against a pre-populated source cache, for cache entries that hold no extracted source, for an archive that carries no readable source at all, for the bounds of the cache reset, for a spec smuggled through a dependency name in each ecosystem, for--ignore-scriptsreaching the npm process, for a symlink out of an extracted archive, for incomplete coverage in every output format, and for manifest-supplied paths in their Unix and Windows spellings. Each was confirmed to fail at runtime against the code it guards, never merely to fail compilation.Mutation matrices were run over these guards, and they are reported with their survivors rather than as a blanket claim, because an earlier draft of this entry said each guard had been mutation-tested while several mutations survived. Four rounds were run and each round's survivors were closed by a further test before the next. The ones that mattered: a threshold no fixture could pin from below, a bound whose removal no fixture could detect, three of the four archive-extraction sites with no test at all, and a disclosure asserted in only one of the five output formats. Real npm, PyPI and Go names are asserted to pass the grammars, so a guard tightened far enough to refuse a legitimate name fails in the suite rather than in a user's CI; that assertion covers the grammar rather than a completed fetch.
- Added
github.com/BurntSushi/tomlfor pyproject.toml and Pipfile parsing.
Present in 1.2.2 as well unless noted. Each was reproduced by hand against both the 1.2.2 and the 1.3.0 binary during the release test, and each is tracked.
-
NEW in 1.3.0: a PyPI package that publishes no wheel is no longer analyzed. This is the cost of the fix above:
pip downloadnow runs with--only-binary=:all:, so a package distributed only as an sdist is reported as not examined rather than built. The reason is named on stderr and carried in every format. Preferring an unexamined dependency to an executed one is the trade this release makes deliberately; a source-build mode behind an explicit opt-in is the shape of the fix if the coverage turns out to matter. -
How much of a dependency tree a scan covers depends on the ecosystem, and the documentation stated it wrongly in both directions. Measured per ecosystem against this binary:
- Go: indirect requirements ARE read. A
go.moddeclaring one direct require and a// indirectblock of two reportstotalDependencies: 3, directDependencies: 1, and the indirectgolang.org/x/cryptocontributes five findings of its own. For Go 1.17 and later the indirect block is the transitive closure, so a tidiedgo.modgives full coverage. - npm, Python and Maven: declared dependencies only. Adding a
package-lock.jsonthat declares a transitive dependency leaves both counters at 1, and the same holds forpoetry.lock.
Lock files are not reported as unsupported, which is a distinction this release cares about:
package-lock.jsonandpoetry.lockleave the document'sskippedfieldnull, so they are invisible rather than refused. ACargo.toml, which genuinely has no parser, IS reported with"reason": "no parser for this manifest type". So a lock file is a skip-by-omission of exactly the kind disclosed two entries below.The README previously claimed "Scans all transitive dependencies, not just direct ones" and the root help listed "Full dependency tree analysis", which overstated coverage. A first correction in this release then understated it, by generalising an npm measurement to all four ecosystems and telling Go users their indirect dependencies were out of scope when they are read and reported. Both are now stated per ecosystem, which is the only form of this claim that is true. Parsing lock files for npm, Python and Maven is the fix, and it is not in this release.
- Go: indirect requirements ARE read. A
-
A version can name any URL, and the scanner will fetch it. npm's own semantics allow a dependency version to be a tarball URL or an alias (
npm:other@1.0.0), and neither is screened beyond the local-path checks. So a manifest under scan can direct the fetch at a host of its choosing, including one on the scanning machine's own network, and the response bytes are then handed totar. An alias additionally makes the report key a different package's findings to the declared name. Pre-existing. Closing it needs a host policy rather than a path check. -
--deepon a Go project writes to the tree under scan.go mod downloadis run without a working directory of its own, so it inherits the invocation directory and can writego.suminto the project being scanned. A read-only scanner that dirties the working tree breaks agit diff --exit-codecheck in CI. Pre-existing. -
A database record can answer for a different version than the one declared, and nothing says so.
github.com/cloudflare/circl v1.3.9is reported withML-KEMandML-DSAas SAFE from a record built for v1.6.4. A full-text scan of the real v1.3.9 module finds no ML-KEM or ML-DSA at all: it ships round-3 Kyber and Dilithium (kem/kyber/kyber768,sign/dilithium/mode2), which are not interoperable with the FIPS standards.org.bouncycastle:bcprov-jdk18on 1.78.1is the same case: nopqc.crypto.mlkempackage exists in it, onlypqc.crypto.crystals.kyber. This is the worst direction for this tool to be wrong in, because a project appears post-quantum ready while shipping pre-standard primitives, and it reaches the README's own example, whosego.moddeclarescircl v1.3.7. Present identically in 1.2.2, and in the embedded database as well as the downloadable one, so--offlinedoes not avoid it. Only--format jsonreveals the version a record was built for. -
A file skipped by name is not analyzed and not counted. The JavaScript walker skips
*.min.jsandtest/,tests/and__tests__/directories before the analyzer sees them, so unlike a file that is refused, they appear in neitherfilesAnalyzednorfilesUnreadable, and nothing on any stream or in any format mentions them. Abundle.min.jsof 162,025 bytes callingcrypto.createHash('md5')produces no finding and no trace, while a file of byte-identical content nameddist/app.jsis analyzed and its MD5 reported. The difference is the filename alone. Present in 1.2.2, which additionally has no counts at all, so this is not a regression; it is disclosed here because it is the same silent-skip shape this release exists to close, and because renaming a file is a cheaper evasion than choosing an encoding. Counting a skip-by-policy alongside a refusal is the fix. -
requirements.txtsplits a dotted PyPI name at the first dot.zope.interface==6.1parses as namezopeand version.interface==6.1, and the version guard then refuses that as a local path, which is a misleading message for a parse defect upstream of it. Bothzopeandruamelare real PyPI packages, so the wrong name reaches every output format. 323 of the top 15,000 PyPI packages have a dotted canonical spelling. The same packages parse correctly throughpyproject.tomlandPipfile, which normalise the name first, so only therequirements.txtpath is affected. Present identically in 1.2.2. -
NEW in 1.3.0: a source file whose first 1024 bytes are mostly non-ASCII is not read. The text check judges a file on its head, and accepts a head that is not valid UTF-8 only if it is predominantly printable ASCII. That covers source in a single-byte encoding, where the code is ASCII even when its comments are not, and it does not cover a file that opens with a long comment in a non-Latin single-byte encoding, such as a cp1251 licence header. UTF-16 source is refused for a separate reason: it carries NUL bytes. In every case the file is now counted in
analysis.filesUnreadable, named on stderr, and disclosed as a coverage note in all five formats, so the gap is stated rather than silent. Reading whole files, or detecting the encoding, is the larger fix. -
A package that provides post-quantum cryptography is reported as quantum-vulnerable, from algorithms it does not contain. A project whose only dependency is
@noble/post-quantumis reported with four HIGH findings and advised to migrate to ML-KEM and ML-DSA, which is what the package implements.The records must be read against the version they were BUILT for, not the version a manifest declares, which is the limitation stated immediately above and which is easy to fall into while investigating this one. A manifest declaring
@noble/post-quantum@0.2.0is answered by a record built for0.6.1, and@noble/hashes@1.4.0,@noble/ciphers@1.0.0and@noble/curves@1.4.0are all answered by records built for2.2.0. Checked against those published artifacts:@noble/post-quantum@0.6.1: mostly correct, and one fabrication.ECDSA,Ed25519andX25519are all genuinely present, inhybrid.js, whose own comment describes a preset combining ML-KEM-768 with X25519, andAESandChaChaare present infalcon.js. So these findings really are the classical halves of hybrid constructions, and reporting them as standalone quantum-vulnerable usage is a classification defect rather than a fabricated one.RSAis fabricated: it occurs nowhere in the package.@noble/hashes@2.2.0, a hashing library: four fabrications. It is credited withRSA,ECDSA,Ed25519andX25519, none of which occur anywhere in its source.@noble/ciphers@2.2.0is credited with the same four, likewise absent.@noble/curves@2.2.0is credited withRSA,AESandChaCha20-Poly1305, all absent, while itsECDSA,X25519andEd25519are real.
So there are two distinct defects behind one symptom: a real hybrid component classified as if it were standalone classical usage, and primitives inferred into records that do not contain them. The second comes from name inference, which supplies 780 of the 849 records in the database this release publishes, and 832 of the 901 in the newer one a machine may already have cached; the two are distinguished below. Correct classification of a declared hybrid component, and a verification pass over the inferred records, are the fixes, and neither is in this release.
-
Following the tool's own remediation advice does not change the verdict. A five-dependency project that replaces
node-forgeandellipticwith the@noblelibraries the report names in its ownLibraries:field, removing DES, 3DES, MD5, SHA-1, secp256k1 and ECDH, produces a byte-identical summary and the same exit 1:5 deps | 4 with crypto | 10 vulnerable | 3 partialbefore and after. The cause is the inferred attribution above, which gives four single-purpose@noblelibraries the same classical core, so the migration target carries the same findings as the thing being migrated away from. Identical on 1.2.2. Until the inferred records are corrected, treat the remediation list as a pointer to the right family of libraries rather than as a step that a rescan will confirm. -
--offlinedoes not prevent network access for a GitHub-shaped argument. The flag is documented as "Only use local database, no downloads", and it does gate the database download, but the GitHub fetch path is not behind it:analyze owner/repo --offlineclones the repository and scans it. A mistyped local path with exactly one slash is read asowner/repo, so a typo becomes an outbound request to api.github.com. Identical on 1.2.2. Anyone relying on--offlineto mean no egress should not pass a repository argument. -
Maven coverage in the downloadable database is unstable. The weekly refresh has published between 15 and 356 Maven packages over the last seven runs, because a partial result from the upstream search is committed as though it were a complete one. The 15 that are always present are a curated seed list. This affects
cryptodeps updateonly; the binary's built-in database is unchanged by it. -
Findings carry no line number. JSON
location.fileis empty andlocation.lineis zero for every finding, and SARIF results carry noregion, so an alert lands on the manifest rather than on the line that declares the dependency. -
A
GITHUB_TOKENin the environment breaksanalyze <url>. The GitHub API is called with whatever token is present, so an expired or wrongly-scoped one fails with401 Unauthorizedagainst a public repository that needs no authentication at all.GITHUB_TOKENis set by default in GitHub Actions. Clearing it for the command is the workaround. -
The database download is unverified, and it is not opt-in.
update --urlaccepts any URL, and the database file carries no signature or checksum, so the update path is unauthenticated end to end. The download is also automatic and silent: the firstanalyzeon a machine with no~/.cryptodepsfetches the database over the network and writes it there without printing anything, so a user who never runsupdateis still scanning against downloaded data. That is why the same project can be reported differently on two machines. Pass--offlineto use only the database built into the binary, which is smaller (72 packages against 849 in the asset this release publishes) and is curated rather than inferred: it does not carry@noble/post-quantumat all, and its@noble/hashesrecord lists only real hash algorithms (BLAKE2b, BLAKE2s, BLAKE3, SHA-256, SHA-384, SHA-512, SHA3-256) with none of the fabrications the inferred record carries.Two different databases report the same version label. The asset published with this release holds 849 records, 780 of them inferred, and calls itself version 1.1.0; a database downloaded on 2026-07-27 holds 901 records, 832 of them inferred, and also calls itself version 1.1.0. The label does not identify the knowledge base, and no scan output records which one answered, so two runs that disagree cannot be told apart from their reports. Both numbers appear in this changelog and each is stated with the database it describes.
-
--deeprequirespiponPATHfor Python packages, notpip3, so it fails on a default Homebrew macOS withexec: "pip": executable file not found. The failure is reported on stderr and the report now says the packages could not be read and points at those warnings, rather than suggesting the command that just failed, but the missingpip3fallback itself is not fixed. -
A repository whose only manifests are of an unsupported type exits 2, with the same status as a genuine analysis error. 1.3.0 now names the file and the reason rather than reporting that no manifest was found.
-
--fail-on anyfails a project whose cryptography is entirely quantum safe, and reports it as a partial-risk exit. The flag is described as exiting non-zero "when risk found", but it is implemented as any cryptographic usage at all, so a project whose only dependency isbcryptexits 3 with0 vulnerable | 0 partialin its own summary and nothing on either stream explaining the failure. Exit 3 is documented as partial-risk findings, which this is not. Identical on 1.2.2. The default,--fail-on vulnerable, is unaffected and behaves as documented. -
Two dependency names that differ only in characters the source cache replaces share one cache entry. The cache path is built by replacing anything outside
[A-Za-z0-9._-]with_, so@scope/pkgand_scope_pkgboth give_scope_pkg, and the second package declared in a manifest is analyzed from the first one's source and reported under its own name. It needs a manifest that declares both spellings, and it misattributes findings rather than reaching anything outside the cache. Present in 1.2.2. Closing it changes the cache path of every Maven coordinate and every scoped npm name, and those paths appear in reported findings, so it is held for 1.3.1 rather than changed in a release whose output has already been verified. -
The bundled GitHub Action scans against the built-in database and reports its version as
dev.action.ymlpasses--offlineat both of its scan steps and a fresh runner has no~/.cryptodeps, so the Action sees the 72 packages built into the binary rather than the 849 in the downloadable database. The coverage note fires, so the scan is not silently narrower, but nothing states that the Action's database is a twelfth of the one a local install downloads. Separately, the Action installs withgo install ...@latest, which injects no ldflags, so the binary it runs reports versiondevand the SARIF it uploads carries that as its driver version. That is the version-provenance defect this release fixes, reintroduced by the install method rather than by the binary. -
The CBOM component list is not a complete bill of materials. It carries the libraries a scan has findings for, so a dependency that was examined and found clean is absent from the components alongside one that could not be examined at all. Incomplete coverage is now reported as a property of the scan, so the gap is stated rather than silent, but a reader who takes the component list as the set of dependencies will undercount. Making the list complete is an output change held for a later release.
-
Some CBOM primitive values are schema-legal but not the closest available term.
ML-KEMis mapped tokey-agreewhere the CycloneDX enum offerskem, AEAD ciphers tootherwhere it offersae, HMAC tosignaturewhere it offersmac, and bcrypt, scrypt and Argon2 tohashwhere it offerskdf. The document validates, and mapping the flagship post-quantum KEM imprecisely in a document whose purpose is post-quantum readiness is the one that matters. -
The per-analysis provenance block carries zero values. Every deep-analyzed record emits
"date": "0001-01-01T00:00:00Z","method": "","tool": ""and"toolVersion": ""insideanalysis.analysis. The tool version fixed in this release is the top-leveltoolobject, the SARIF driver and the CBOM metadata, which are correct; this separate per-record block is unchanged and is a placeholder rendered where a reader looks for provenance. Identical in 1.2.2. Either populate it at the point of analysis or omit it. -
--offlinesilently disables--deep, and the report then suggests--deep. Source analysis fetches package archives, so it cannot run with downloads refused, but passing both prints no warning that one was ignored. Because no source analysis was attempted, a scan whose dependencies are all absent from the database ends at "Run with--deepto analyze package source code directly", which is the flag that was just passed. 1.2.2 answered the same invocation with "No cryptographic usage detected in dependencies", a clean verdict for a scan that examined nothing, so the verdict itself is fixed and the suggestion that follows it is not.
1.2.2 - 2025-12-27
- Docker image name in the release workflow now matches the repository, so the GHCR login succeeds and the container image publishes.
1.2.1 - 2025-12-27
- Expanded remediation database: 30+ additional algorithm entries
- Authenticated encryption: ChaCha20-Poly1305, AES-GCM, XSalsa20-Poly1305
- MACs: HMAC, HMAC-SHA256/512, Poly1305
- Post-quantum algorithms: ML-KEM, ML-DSA (marked as quantum-safe)
- NIST curves: P-256, P-384, P-521, secp256k1
- RSA variants: RSA-OAEP, RSA-PSS, PS256/384/512
- ECDH variants: ECDH-ES
- Hash functions: BLAKE2b, BLAKE2s, BLAKE3
- Chinese national algorithms: SM2, SM3, SM4
- @noble/ed25519 false positives: Database entry incorrectly reported RSA, ECDSA, ECDH, AES; now correctly shows only Ed25519 and X25519
- Maven property resolution: Parser now resolves
${property}placeholders from<properties>section (e.g.,${bouncycastle.version}→1.77) - AES remediation: Added generic "AES" entry for cases where key size isn't specified
1.2.0 - 2025-12-27
- Workspace & monorepo support: Automatically discovers all manifest files in project directories
- npm/yarn workspaces via
package.jsonworkspaces field - pnpm workspaces via
pnpm-workspace.yaml - Go workspaces via
go.workfiles - Recursive directory walking with smart filtering (skips node_modules, vendor, .git, etc.)
- npm/yarn workspaces via
- Multi-project output: Aggregated results across all discovered projects
--no-workspacesflag: Disable workspace discovery for single-manifest scanning
- Output formatting: Clean, professional terminal design with colored status indicators
- Vulnerable (quantum-broken by Shor's algorithm)
- Partial risk (weakened by Grover's algorithm)
- Safe (quantum-resistant)
- Improved remediation guidance layout with aligned fields
- Call trace formatting now uses
>prefix for cleaner output
1.1.0 - 2025-12-26
- GitHub URL scanning: Analyze any public GitHub repository directly without cloning
- Full URL support:
cryptodeps analyze https://github.com/owner/repo - Shorthand support:
cryptodeps analyze owner/repo - Branch/path support:
cryptodeps analyze https://github.com/owner/repo/tree/main/subdir
- Full URL support:
- Dynamic database updates: Fetch crypto packages from npm, PyPI, Go, and Maven registries
- Weekly auto-update workflow: Database automatically refreshes every Monday
- Algorithm inference: Intelligent detection of crypto algorithms from package metadata
- Confidence levels: Packages marked as
verified,high,medium, orlowconfidence - Demo project:
examples/vulnerable-demoshowcasing quantum-safe and vulnerable crypto
- Database expanded from 69 to 1,122 packages
- Improved snapshot versioning for non-semver tags
- GoReleaser build failures with database release tags (db-*)
1.0.0 - 2025-12-26
- Initial release of QRAMM CryptoDeps
- Multi-ecosystem support: Go (go.mod), npm (package.json), Python (requirements.txt, pyproject.toml), Maven (pom.xml)
- Quantum risk classification: VULNERABLE, PARTIAL, SAFE categories
- Output formats: Table, JSON, CycloneDX CBOM, SARIF, Markdown
- CI/CD integration: Exit codes for pipeline automation
- On-demand analysis: AST-based source code analysis with
--deepflag - Database: 69 curated crypto-using packages with verified algorithms
- Remediation guidance: Actionable recommendations for each finding
- Identifies quantum-vulnerable algorithms (RSA, ECDSA, Ed25519, DH, DSA)
- Maps findings to CNSA 2.0 compliance requirements
- Supports OMB M-23-02 cryptographic inventory requirements