Skip to content

feat!: authenticate the cache, contain config paths, report what was not scanned (1.3.0) - #39

Merged
aksOps merged 3 commits into
mainfrom
fix/v1.2.1-trust-boundary
Aug 4, 2026
Merged

feat!: authenticate the cache, contain config paths, report what was not scanned (1.3.0)#39
aksOps merged 3 commits into
mainfrom
fix/v1.2.1-trust-boundary

Conversation

@aksOps

@aksOps aksOps commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Closes the confirmed blocker and twelve concerns from the v1.2.0 production gate review, plus three blockers and nine minors the adversarial re-review found in the fixes themselves. Cut as 1.3.0: new exit-2 conditions and a new public field are not patch-safe.

The blocker

A repository could commit a poisoned .siloscan/cache and a fresh clone would scan clean over a live credential - exit 0, no warning, nothing. Content-hash keying does not help, because the attacker's entry key legitimately matches the file. Cache entries are now authenticated per cache directory: a salt bound to the cache location, an authentication tag over the whole stored entry, and a mismatched or foreign tag is a miss (a real scan), never a trusted zero. Reproduced in its strongest form during verification - forging a valid tag under the attacker's own salt, proven to suppress the finding in their checkout - and defeated in a fresh clone by location binding alone.

On Windows the salt lives in an NTFS alternate data stream, which no archive or git checkout carries; unsupported platforms trust no salt and scan cold. The Windows runtime path is type-checked cross-target but not executed here - failure direction is a cold cache, never a false clean.

Trust boundary

  • Config path containment now resolves symlinks. The lexical guard was bypassable with 'ln -s ../outside link' plus 'rules = ["link"]'. Both the config and the symlink are tree content.
  • Project ignore matchers are rooted at the declaring file. ignore's add_ignore roots matchers at the process CWD, so an anchored pattern like '/modules/api/secrets/' hid a credential when run from the repo root and reported it when run from /tmp. Same tree, same command, different answer.
  • Zero rules, unusable coverage reports, and escaping config paths exit 2. A typo'd --rules path was a permanently green CI job; a coverage gate with no report, or one matching no scanned file, passed silently. A gate that cannot evaluate must fail.

Saying where it did not look

  • Ignored file and directory counts in JSON, SARIF (siloscan/ignored) and the human summary, so 'clean' is distinguishable from 'did not look'. An in-root .gitignore still excludes files - now counted. Costs +0.26s on an 88k-file tree, measured and documented.
  • anchor=config consults ignore files between the config root and the scan root, restoring the module/root scan interchangeability that 1.2.0 broke for shared baselines.

Interop and robustness

  • SARIF URIs percent-encoded (unencoded paths were non-conformant and truncated at '#') and columns character-based (17 vs 29 after a CJK prefix). ASCII-only output is byte-identical to 1.2.0 apart from the version string.
  • The terminal sanitizer also escapes Unicode bidi controls, closing the Trojan Source half of the forgery hole. Arabic and Hebrew text is untouched.
  • stderr writes tolerate a broken pipe: 80 runs, exit codes only ever 0/1/2, never 101.
  • A foreign cache salt is replaced rather than permanently disabling the cache; entry authentication coverage is guarded by exhaustive destructuring so a new field cannot silently fall outside the tag.

Verification

  • 807 workspace tests; clippy -D warnings and fmt clean; MSRV 1.96 checked (1.95 correctly rejected).
  • All 11 repro groups from this round plus every repro from the two previous rounds re-run against the release binary.
  • Determinism: a 6064-finding fixture (hidden files, binaries, ignored files, an oversized file with a boundary rule, secrets, cross-silo imports) gives one stdout hash and one stderr hash across 1/4/8 workers x cold/warm/no-cache.
  • Fingerprint stability: 1.2.0 vs this branch on that fixture - identical finding sets, zero fingerprints changed.

…was not scanned (1.3.0)

The scanned tree is untrusted input. This release closes the paths by
which a repository could weaken its own scan, and makes the places the
scanner does not look visible instead of silent.

BREAKING: a scan that loads zero rules, a coverage rule with no usable
report, and a discovered config whose rules/source_roots/include paths
resolve outside the config root are now exit-2 errors rather than clean
exits. CoverageReport gained a public field.

- cache entries are authenticated per cache directory, so a .siloscan
  committed into a repository is ignored rather than trusted (unix mode
  binding plus location binding; NTFS alternate data stream on Windows)
- config path containment resolves symlinks, closing the lexical bypass
- project ignore matchers are rooted at the file that declares them, so
  results no longer depend on the process working directory
- anchor=config consults the project's own ignore files, restoring
  module/root scan interchangeability for shared baselines
- ignored file and directory counts in JSON, SARIF and human output
- terminal sanitizer also escapes Unicode bidi controls
- stderr writes tolerate a broken pipe instead of panicking to 101
- SARIF URIs are percent-encoded and columns are character-based
Comment thread crates/siloscan-core/src/cache.rs Fixed
aksOps added 2 commits August 4, 2026 15:10
The cache salt was folded together from /dev/urandom, a RandomState-keyed
hash of hard-coded tuple constants, the process id, the wall clock and the
directory path. CodeQL flagged it (rust/hard-coded-cryptographic-value) and
the shape was wrong: values this file spells out reached a value used as a
salt, and the non-unix path had nothing but them behind it.

The operating system is now the only source. generate_salt reads SALT_LEN
bytes from /dev/urandom and returns an Option: no device, a short read, or a
platform without one means there is no salt at all. No salt is written, no
entry authenticates, every lookup is a miss and the scan runs cold. A cold
scan is a correct scan; a guessable salt is a forgeable tag, so that is the
direction to fail in.

Drops os_random_bytes along with the pid, clock, counter and path padding a
fully random salt never needed, and builds both generate_salt and unhex by
accumulating bytes rather than overwriting a zeroed array.
The salt came from /dev/urandom, which exists on unix and nowhere else,
so Windows - a shipped release target - could never write one and its
cache was permanently cold. Take the bytes from getrandom instead: it
asks each target for the random source that target has, and std exposes
no OS random API on stable, so this is a dependency or it is nothing.

The fail-closed contract is unchanged. A source that errors is None, and
None is no salt, no write, and a miss on every entry. The buffer is
uninitialized rather than zeroed and the salt is built from what the call
returns, so no value spelled in this file can reach an authentication tag.

The platform provenance checks are untouched: an owner-only mode on unix,
an alternate data stream on Windows, nothing trusted anywhere else. The
Windows branch is reachable again now that a salt can exist there.
@aksOps
aksOps merged commit 4b040ea into main Aug 4, 2026
8 checks passed
@aksOps
aksOps deleted the fix/v1.2.1-trust-boundary branch August 4, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants