Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ build/
.env.local
config.local.yaml

# Credentials and key material
secrets.json
*.pem
*.key
*.p12
*.pfx

# AI assistant files
CLAUDE.md
.cursorrules
Expand Down
106 changes: 106 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Changelog

All notable changes to CryptoScan are recorded here. This project follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **Operational crypto was silently dropped by the noise filter.** The scanner
decided whether a match was low-value by testing the whole source line
against a list of about a hundred substrings, and dropped the finding on any
hit. Three of those entries matched ordinary code:
- `".md"`, intended for Markdown filenames, matched every `.md5(` in the
corpus. `a.md5(b)`, `hashlib.md5(b"a")` and
`return hashlib.md5(data).hexdigest()` all reported zero findings.
- `"cipher ="` matched `Cipher cipher = Cipher.getInstance("DES")`, the
conventional way to name a `javax.crypto.Cipher`.
- `"const "` matched `const cipher = crypto.createCipheriv(...)`, which
`prefer-const` makes the idiomatic form in JavaScript.

The heuristic has been replaced by an evidence classifier anchored to the
position of the match. The classifier asks whether the token is part of an
operation *before* it applies any noise heuristic, so a call, a member
access, an import or an argument to a crypto factory is always reported,
whatever else appears on the line.

That ordering is the safety property, and getting it wrong is subtle. An
intermediate version of this change ran the log, comment and path rules
first, which reintroduced the same defect class through a different door: a
variable named `outputs` (containing `puts `) hid
`Cipher.getInstance("RC4")`, a `z--` decrement hid a DES cipher in Java,
`gpg --cipher-algo 3DES` read as a comment, and adding a fifth entry to an
`sshd_config` `MACs` line took the file from three findings to zero. All of
those are now regression tests.

- **No rule classifies by vocabulary any more.** Two heuristics judged a match
by the words around it, and both were false-negative generators that took
three revisions to stop tuning and simply remove:

- A quoted string of three or more words counted as prose. A quoted string is
where configuration lives, so this hid
`db.Exec("ALTER SYSTEM SET password_encryption TO 'md5'")`,
`props.put("jdk.tls.client.cipherSuites", "RC4 and DES ...")`,
`conf.set("hive.ssl.ciphers", "DES-CBC3-SHA and RC4-MD5 for all peers")`
and nine other real settings.
- A leading `description:` or `title:` owned the rest of its line, losing a
CRITICAL finding to every key that was not on the list: `note:`, then
`"title" :` with a space, then `:title =>`, then `ssl-title:`.

What remains is structural. A match is withheld only when it belongs to a
logging or error call, sits in a comment in a language that has that comment
marker, or is inside a URL or document path. Suppressing a description field
now needs no rule at all, because it is simply reported.

- **Declared cryptographic configuration is reported, whatever the key is
called.** `cipher: DES-CBC`, `note: DES-CBC` and `usage: DES-CBC` are the
same finding. An intermediate version treated `note`, `remarks`, `comment`,
`help`, `usage` and `example` as documentation labels, which meant renaming
a config key by one word hid a CRITICAL DES finding and flipped
`--fail-on critical` from exit 1 to exit 0. Also `cipher: DES-CBC`,
`MACs hmac-md5 ...`, `SSLProtocol +SSLv3` and `ssh-keygen -t rsa -b 1024`
are findings. For a cryptographic inventory a declared algorithm *is* the
inventory, and an Apache config enabling SSLv3 must fail
`--fail-on critical`. Scanning a repository that ships cryptographic
reference data will now report a finding per row; use `--exclude`.

- **Suppression is counted and recoverable.** Findings held back as prose, log
output, comments, URLs or documentation labels are reported as a count in the
scan summary, and `--include-narrative` (also covered by `--verbose`) shows
them. The count is computed after deduplication, so it is exactly the number
of findings the flag adds, and the default report is a strict subset of what
the flag shows: deduplication now prefers an operational match over a
narrative one at the same location, so enabling the flag can only add.
Detected key material is never held back, since a private key in a comment is
still an exposed private key.

- **`cryptoscan:ignore` no longer blinds the following line.** A trailing
directive suppressed both its own line and the next one. A directive now
applies to its own line unless it says `cryptoscan:ignore-next-line`.

- **Go DES usage was undetectable.** `des.NewCipher` and
`des.NewTripleDESCipher` are the Go standard library's only DES constructors
and no pattern matched them.

- **Every output surface reported a different version.** One binary reported
`1.4.0` from the `version` command, `1.0.0` in SARIF, `1.1.0` in CBOM, and no
version at all in JSON. A CBOM naming the wrong producer is a false
provenance record. All surfaces now read `pkg/version`, and JSON carries a
`tool` object.

- **Finding order was nondeterministic.** Results were built by ranging over a
map, so two scans of an unchanged tree emitted the same findings in a
different order. This broke diffable CI output, golden-file tests and
reproducible CBOMs.

### Added

- `--include-narrative` flag to show algorithm mentions held back as text.
- `tool` object in JSON output, carrying the scanner name and version.
- `narrativeSuppressedCount` in the scan summary.

## [1.3.0]

See the [release notes](https://github.com/csnp/cryptoscan/releases) for
versions 1.3.0 and earlier.
44 changes: 43 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,10 @@ Flags:
-c, --context int Lines of source context to show (default 3)
-p, --progress Show scan progress indicator
--min-severity string Minimum severity to report: info, low, medium, high, critical
--include-imports Include library import findings
--include-quantum-safe Include quantum-safe algorithm findings (SHA-256, AES-256)
--include-narrative Include algorithms named in prose, logs, docs and config keys
-v, --verbose Show all findings, including all three categories above
--no-color Disable colored output
--pretty Pretty print JSON output
-h, --help Show help
Expand Down Expand Up @@ -332,6 +336,44 @@ cryptoscan scan . --exclude "vendor/*,node_modules/*,*_test.go"
cryptoscan scan . --min-severity critical --format json | jq '.findings | length'
```

### What is reported, and what is held back

CryptoScan asks one question first: is the matched token part of an operation?
A call, an instantiation, a member access, an import, or an algorithm name
passed to a crypto factory is always reported, whatever else appears on the
line. So all of these produce findings:

```
hashlib.md5(data)
des.NewCipher(key)
const cipher = crypto.createCipheriv('des-ede3-cbc', k, iv)
Cipher.getInstance("RC4")
```

Declared configuration is also reported. For a cryptographic inventory, an
algorithm named in a config file is the inventory:

```
cipher: DES-CBC
MACs hmac-md5 hmac-sha1 umac-64
SSLProtocol -all +SSLv3 +TLSv1
ssh-keygen -t rsa -b 1024 -f id_rsa
```

Only mentions in *text* are held back: prose, log and error messages, comments,
URLs, and the values of documentation labels such as `description:` and
`title:`. The scan summary always says how many were held back, and the number
is exactly what the flag returns:

```
14 finding(s) withheld as documentation, log or comment text.
Show them with: cryptoscan scan . --include-narrative
```

Scanning a repository that ships cryptographic reference data (a database of
packages and the algorithms they use, for example) will report a finding per
row. Use `--exclude` to skip those paths.

### Suppressing False Positives

Use inline comments to suppress findings that are intentional or not applicable:
Expand All @@ -352,7 +394,7 @@ legacyKey := oldCrypto.NewKey()
```

Supported directives:
- `cryptoscan:ignore` — Ignore all findings on this line
- `cryptoscan:ignore` — Ignore all findings on this line, and only this line
- `cryptoscan:ignore RSA-001` — Ignore specific pattern ID
- `cryptoscan:ignore RSA-*` — Ignore pattern family (wildcard)
- `cryptoscan:ignore-next-line` — Ignore finding on the following line
Expand Down
161 changes: 114 additions & 47 deletions docs/testing/release-smoke.md
Original file line number Diff line number Diff line change
@@ -1,54 +1,121 @@
# Release Smoke Test: cryptoscan

Manual pre-release walkthrough. Run this before every deploy or publish.
Use real keyboard and mouse input, not synthetic events. Record the actual
output, not a summary.

## 1. Build and start clean

- [ ] Fresh install succeeds (`npm ci` or equivalent) with no errors.
- [ ] Build succeeds (`npm run build`).
- [ ] App starts locally (`npm run dev`) with no console errors.

## 2. Core user paths

List the three most important things a real visitor does on this site or
with this tool, then walk each one by hand.

- [ ] Path 1:
- [ ] Path 2:
- [ ] Path 3:

## 3. Accessibility and audience fit

CSNP serves non-technical and vulnerable users. Verify the experience holds
for them.

- [ ] Plain language: no unexplained jargon in user-facing copy.
- [ ] Keyboard navigable: every interactive element reachable by Tab.
- [ ] Screen-reader labels present on buttons, links, and form fields.
- [ ] Color contrast meets WCAG AA.

## 4. Responsive parity

- [ ] Mobile (375px): nav, menus, and layout all work.
- [ ] Desktop (1280px): no overflow or broken grids.
- [ ] Dark mode (if supported) renders correctly.

## 5. Data and links

- [ ] Every download link resolves (no 404s).
- [ ] Any displayed number or statistic traces to a real source.
- [ ] No placeholder or fabricated content shipped.

## 6. Security

- [ ] No secrets in the bundle or committed files.
# Release smoke test: cryptoscan

Manual pre-release walkthrough. Run this before every tag push. Record the
actual output, not a summary. CryptoScan is a Go CLI, so build the
CI-equivalent binary rather than a plain `go build`: the release injects the
version, and a plain build carries the `dev` default, which hides every
version-provenance defect.

## 1. Build the artifact the release will ship

```bash
go build -ldflags "-X main.version=<version being released>" -o /tmp/cryptoscan ./cmd/cryptoscan
/tmp/cryptoscan version
```

- [ ] `go build ./...` succeeds.
- [ ] `go test -race ./...` passes.
- [ ] `go vet ./...` is clean.
- [ ] `cryptoscan version` prints the version being released, not `dev`.
- [ ] `main.version` is a `var`, not a `const` (a `const` silently ignores `-X`).

## 2. Detection: operational crypto is reported

Every line below must produce at least one finding. Each one reported zero
findings at some point in the tool's history, because a noise filter matched a
substring elsewhere on the line.

```bash
d=$(mktemp -d)
printf 'a.md5(b)\n' > $d/a.py
printf 'return hashlib.md5(data).hexdigest()\n' > $d/b.py
printf 'cipher = md5(b"a")\n' > $d/c.py
printf 'block, err := des.NewCipher(key)\n' > $d/d.go
printf "const x = crypto.createHash('md5')\n" > $d/e.js
printf 'const rc4 = CryptoJS.RC4.encrypt(data, key)\n' > $d/f.js
printf 'Cipher cipher = Cipher.getInstance("RC4");\n' > $d/g.java
/tmp/cryptoscan scan $d --format json | python3 -c 'import sys,json;print(len(json.load(sys.stdin)["findings"]),"findings")'
```

- [ ] At least 7 findings (one per file). Zero for any file is a release blocker.
- [ ] Scan the repo's own `crypto-samples/` tree: the deliberately vulnerable
samples are reported, including the DES, 3DES, RC4 and MD5 cases.

## 3. Detection: mentions in text are withheld, visibly and recoverably

- [ ] A log line (`log.Printf("falling back to md5")`) produces no finding.
- [ ] The summary states how many mentions were withheld.
- [ ] `--include-narrative` brings them back.
- [ ] Detected key material is never withheld, even inside a comment.

## 4. Suppression comments

```bash
printf 'import hashlib\nA=hashlib.sha1(b"1") # cryptoscan:ignore\nB=hashlib.sha1(b"2")\nC=hashlib.sha1(b"3")\n' > $d/ig.py
```

- [ ] Findings are reported on lines 3 and 4 only. Line 2 is suppressed and no
other line is affected by it.
- [ ] `# cryptoscan:ignore-next-line` suppresses the following line.
- [ ] `cryptoscan:ignore RSA-001` suppresses only that pattern.

## 5. Every output surface agrees

```bash
for f in json sarif cbom; do /tmp/cryptoscan scan $d --format $f; done
```

- [ ] JSON `tool.version`, SARIF `runs[0].tool.driver.version` and
`.semanticVersion`, and CBOM `metadata.tools[0].version` all equal the
output of `cryptoscan version`.
- [ ] The CBOM validates against the official CycloneDX 1.6 schema, fetched
from the CycloneDX specification repository, not against the tool's own
tests. Resolve the `jsf-0.82` and `spdx` `$ref`s and validate with
python `jsonschema`.
- [ ] SARIF result locations point at real files.

## 6. Determinism

```bash
for i in 1 2 3; do
/tmp/cryptoscan scan crypto-samples --format json \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print([(f["file"],f["line"],f["id"]) for f in d["findings"]])' \
| shasum -a 256
done
```

- [ ] All three hashes match. Finding order must not vary between runs of an
unchanged tree, or CI diffs and CBOMs are not reproducible.

## 7. Output and exit codes

- [ ] Piped (non-TTY) output contains no ANSI escape sequences.
- [ ] `--no-color` output contains no ANSI escape sequences and no emoji.
- [ ] `--help` lists every flag, and every flag it lists actually parses.
- [ ] An invalid `--format` value is rejected with a clear message and a
non-zero exit code, rather than silently falling back to text.
- [ ] Exit codes match what `--fail-on` documents.

## 8. Findings read well

For each finding in a real scan, check all five:

- [ ] It names the file and line.
- [ ] It says what specifically is wrong.
- [ ] The remediation is a concrete action, not general advice.
- [ ] Severity is coherent against the other findings on the same file.
- [ ] A non-developer security manager would understand what to do.

## 9. Security and hygiene

- [ ] No secrets in committed files.
- [ ] `.env` and credential files are gitignored and excluded.
- [ ] Forms validate and sanitize input.
- [ ] `npx hackmyagent secure --ci` reports no CRITICAL or HIGH findings, or
each one is a documented and verified false positive.

## Result

- Version:
- Date:
- Tester:
- Verdict: PASS / FAIL
Expand Down
10 changes: 7 additions & 3 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import (
"fmt"

"github.com/spf13/cobra"

"github.com/csnp/cryptoscan/pkg/version"
)

var (
version = "dev"
commit = "none"
buildDate = "unknown"
)
Expand Down Expand Up @@ -47,8 +48,11 @@ Examples:
Learn more at https://qramm.org`,
}

// SetVersionInfo records the build metadata injected by GoReleaser. The version
// string is stored in pkg/version so that every output surface reports the same
// value; commit and build date are only shown by the version command.
func SetVersionInfo(v, c, d string) {
version = v
version.Set(v)
commit = c
buildDate = d
}
Expand All @@ -66,7 +70,7 @@ var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("cryptoscan %s\n", version)
fmt.Printf("cryptoscan %s\n", version.Get())
if commit != "none" {
fmt.Printf(" commit: %s\n", commit)
}
Expand Down
Loading
Loading