Skip to content

feat(identifiers): file hashes on the file entity, for risk acceptance by hash - #704

Merged
AlonLiwsky merged 4 commits into
mainfrom
filehash-s4-scope
Aug 31, 2026
Merged

AlonLiwsky merged 4 commits into
mainfrom
filehash-s4-scope

Conversation

@AlonLiwsky

@AlonLiwsky AlonLiwsky commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Why

Risk acceptance compares an exception against one thing only: the identifiers block on the alert. Today that block carries a file name and a directory, and no hash — so an analyst can create an "accept this SHA256" rule and it can never match.

Accepting by hash clears one known-good file by identity. A path exception does not: copy or move the file and it stops applying, and it covers every other file that later lands on that path. This is FR-17.

What changed

  • common.FileEntity gains MD5, SHA1, SHA256; Identifiers.Flatten() emits file.md5, file.sha1, file.sha256.
  • armotypes.NormalizeHashScopeValues — one definition of "which entities are hashes and fold their case", so cadashboardbe and event-ingester-service cannot drift apart.
  • MalwareSignature gets explicit bson tags. See the second note below; this one has a trap in it.

Why adding a field here is enough — and where it is not

Flatten() is the whole contract for the two generic consumers: postgres-connector builds the SQL predicate by ranging over it, and event-ingester-service keys its exception cache the same way. Neither holds a list of known fields, so a new key starts affecting the query and starts separating cache entries at the same moment.

Four surfaces are not generic and are hand-written. All four are changed in this epic's other PRs. None of them was ever changed for network.sourceIP, which is why that field is unusable in the form, unsearchable and unindexed seven weeks after it shipped:

Surface Repo
Risk-acceptance form options armo-ng-app
Incident free-text search cadashboardbe
Mongo index config-service
The producer that fills the value event-ingester-service

There is a third generic consumer in this repoGetRuntimeIncidentsRequestFilterFromExceptionPolicy, which drives the "how many incidents would this affect" preview and the retroactive resolve on a new exception. It needed no change, and now has test coverage for hashes, which it did not have.

Two things worth a reviewer's attention

1. The producer moves repo, deliberately. The execution plan assigns "fill alert Identifiers" to the sensor stream in private-node-agent. The hashes are filled server-side instead, in event-ingester-service. A sensor fix reaches a customer only when they update the node agent, which we do not control; the backend already holds the hash on every agent version. It also repairs path scoping for agents older than private-node-agent 546, which send no identifiers at all. The doc records this as a decision rather than stating it as fact.

2. The bson tags carry no ,omitempty, and that omission is load-bearing. MalwareSignature had no bson tags, so the driver keyed every field by its lower-cased Go name — which means FirstSeen is stored as firstseen while JSON serves first_seen. Renaming the stored key would orphan the value in every existing document, so both names are now written out and pinned by a test. Adding ,omitempty would have silently started dropping empty fields, because time.Time satisfies the driver's Zeroer interface. A test pins the sparse document; a fully populated fixture cannot see that.

Merge order

This is the base library. Merge and tag it first, then the three Go consumers re-pin from the pseudo-version to the tag:

  1. this PR → tag
  2. config-service, cadashboardbe, event-ingester-service
  3. armo-ng-app

Test plan

  • go test ./... — 13 packages, no failures.
  • Every new test was mutation-checked: removing the Flatten() emission fails; adding ,omitempty back fails; deleting an index fails its guard.
  • The identifier keys are a stored contract, so a literal assertion pins them. The existing JSON-derived test cannot fail if a key changes on both sides at once.

Review

An adversarial clean-context review ran before this opened and found the omitempty behaviour change and a casing claim with no code behind it. Both are fixed in the second commit.

AI-skills: armosec-shared-rules:multi-repo-feature-workflow,armosec-shared-rules:fe-design-system

…e by hash

Risk acceptance compares an exception only against the alert's identifier block,
so a hash-scoped exception had nothing to match on: FileEntity carried the file
name and its directory and no hash. Flatten() is the whole contract - both
postgres-connector's SQL predicate and event-ingester's exception cache key range
over it and hold no list of known fields - so adding the three hashes here makes
hash scoping reach the query and the cache with no logic change downstream.

Values are lower case. The comparison is string equality or LIKE, and neither
folds case, so the producer and the exception writer both normalise.

Also writes out MalwareSignature's bson tags. They only spell out what the driver
already did, and the point is FirstSeen: with no tag the driver keyed it by the
lower-cased Go name, so it is stored as "firstseen" while the same field is served
as "first_seen". Renaming the stored key would orphan the value in every document
already written, so both names are now pinned by a test. The doc says why, and
records that an unset FirstSeen is served as year 1 rather than omitted, because
omitempty cannot suppress a struct.

Signed-off-by: Alon Liwsky <alon@armosec.io>
Both were found by an adversarial review that ran the code instead of reading the
comments, and both were wrong in the same direction: the change described itself as
smaller than it was.

The bson tags did NOT "spell out what the driver already does". They carried
",omitempty", and time.Time satisfies the driver's Zeroer interface, so an unset
FirstSeen - and every other empty field - would have started being dropped from
stored documents, splitting the corpus into two shapes. The omitempty is gone, so
the claim is now true, and a test pins the SPARSE document, which the original
fully-populated fixture could never have caught.

The casing contract had no code behind it in this repository and it contradicts
cdr-azure-event-shape.md, which says to match case-insensitively and normalize
nowhere. NormalizeHashScopeValues now lives here, so the two write paths call one
function instead of keeping their own copies, and the doc says why a hex digest is
not an Azure subscription id: no original casing to preserve, no meaning in its
case, and no case-insensitive operator on the Postgres comparison at all.

Also: the doc said the producer is filled in event-ingester-service as a plain fact,
when that is a decision this epic makes and a change of owner from what the plan
says. It is now labelled as such. Hash cases added to the exception-filter table -
that function is a third generic consumer in this repo and it had none. The
identifier keys are a stored contract, so a literal assertion pins them; the JSON-
derived one cannot fail if a key changes on both sides at once.

Signed-off-by: Alon Liwsky <alon@armosec.io>
Copilot AI lite review requested due to automatic review settings August 30, 2026 14:32
@AlonLiwsky AlonLiwsky added the ai-assisted Created through Armosec AI tooling (armosec-shared-rules plugin) label Aug 30, 2026
@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6448fbd2-650d-4159-b610-d3e6732c14d2


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds file-hash identifiers (md5/sha1/sha256) to the runtime alert identifier contract so risk acceptance rules can match specific known-good files by hash, and introduces shared normalization to prevent case-mismatch drift across services. Also hardens Mongo storage compatibility for malware signatures by pinning BSON field names (notably firstseen) with tests and documentation.

Changes:

  • Extend common.FileEntity + Identifiers.Flatten() to emit file.md5, file.sha1, file.sha256 for exception matching.
  • Add armotypes.NormalizeHashScopeValues and tests to ensure hash-scoped exception values are case-folded consistently and stay in sync with Flatten().
  • Add explicit BSON tags for MalwareSignature plus tests/docs to prevent silent key renames and omitempty-driven storage shape changes.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
docs/features/malware-file-hash-identifiers.md Documents the rationale, contracts, and cross-repo surfaces for hash-based identifiers and BSON key stability.
armotypes/malware.go Adds explicit BSON tags to preserve Mongo key names (especially firstseen) and documents the no-omitempty constraint.
armotypes/malware_test.go Pins BSON vs JSON key behavior and guards against omitempty changing stored document shape.
armotypes/exceptionpolicy.go Introduces hash-entity set and in-place normalization for hash-scoped advanced-scope values.
armotypes/exceptionpolicy_test.go Adds coverage for hash scope filter paths and normalization behavior, including drift prevention vs Flatten().
armotypes/common/runtime_common.go Adds file hash fields to FileEntity and emits them from Identifiers.Flatten().
armotypes/common/runtime_common_test.go Extends flatten tests and pins stable identifier key literals for file entities (including hashes).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread armotypes/exceptionpolicy.go Outdated
Comment thread armotypes/common/runtime_common.go Outdated
Comment thread armotypes/common/runtime_common_test.go
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 33317130358

Warning

No base build found for commit 00b9776 on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 48.413%

Details

  • Patch coverage: 14 of 14 lines across 2 files are fully covered (100%).

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 5168
Covered Lines: 2502
Line Coverage: 48.41%
Coverage Strength: 5.76 hits per line

💛 - Coveralls

@coveralls

coveralls commented Aug 30, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 33375796026

Warning

No base build found for commit 00b9776 on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 48.443%

Details

  • Patch coverage: 17 of 17 lines across 2 files are fully covered (100%).

Uncovered Changes

No uncovered changes found.

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 5171
Covered Lines: 2505
Line Coverage: 48.44%
Coverage Strength: 5.75 hits per line

💛 - Coveralls

…promising

Review feedback from Copilot, both fair.

HashScopeEntities was an exported map. An exported map is mutable by any consumer,
and this one decides whether a value is folded before it is compared - so a stray
write anywhere would change matching behaviour globally and silently. It is now
unexported behind IsHashScopeEntity.

The FileEntity comment said the hashes ARE lower case, which reads as a guarantee
this struct does not give. It now says callers are expected to store them lower case
and points at who actually folds them.

Also renames a test case that said "hashes only" while its fixture carried a name.

Signed-off-by: Alon Liwsky <alon@armosec.io>
@AlonLiwsky

Copy link
Copy Markdown
Contributor Author

Thanks — all three taken, in 86aabff.

Mutable exported map. Fair, and it is the one with teeth: that map decides whether a value is folded before it is compared, so a stray write anywhere in the estate would change matching globally and silently. It is now unexported behind IsHashScopeEntity.

The comment promised a guarantee the type does not give. Reworded — callers are expected to store lower case, and the comment now points at who actually folds and why it has to happen on write. The type genuinely cannot enforce it: Flatten() is on the read path, and folding there would rewrite alert data rather than exception data.

Test case named "hashes only" with a name in the fixture. Renamed to what it actually covers — a name and a hash with no directory, which is what a bare file path produces.

@AlonLiwsky

Copy link
Copy Markdown
Contributor Author

Thanks — all three taken, in 86aabff.

Mutable exported map. Fair, and it is the one with teeth: that map decides whether a value is folded before it is compared, so a stray write anywhere in the estate would change matching globally and silently. It is now unexported behind IsHashScopeEntity.

The comment promised a guarantee the type does not give. Reworded — callers are expected to store lower case, and the comment now points at who actually folds and why it has to happen on write. The type genuinely cannot enforce it: Flatten() is on the read path, so folding there would rewrite alert data rather than exception data.

Test case named "hashes only" with a name in the fixture. Renamed to what it actually covers — a name and a hash with no directory, which is what a bare file path produces.

…ay files

The third adversarial review found the same class of defect as the first two: a
comment claiming more than the code does. This one is mine twice over, because I had
already fixed exactly this in config-service and did not carry the fix here. I fixed
an instance, not the class.

The guard said "Derive the list from Flatten() so the two cannot drift". It derived
from a HAND-WRITTEN fixture. I reproduced the reviewer's mutation: add a SHA512 field
to FileEntity and a Flatten case for it - the drift the comment describes - and every
test in the repository still passed, because the fixture never set the new field so
Flatten never emitted it so the loop never checked it.

The fixture is now filled by reflection, and every FileEntity field must be a declared
decision: a hash the fold covers, or a value deliberately left alone because it is
case-significant on Linux. Both mutations now fail loudly - a new field with no fold
rule, and a new field with no Flatten case at all.

Also from the review:
- The FileEntity comment sent the reader to NormalizeHashScopeValues for "who does the
  folding". That function folds the exception side and never touches FileEntity. It
  now names both writers, one per side of the comparison.
- Recorded the residual risk two reviewers have now raised: nothing validates the
  entity name on the way in, so an unrecognised one is stored and silently matches
  nothing. Closing that means rejecting unknown entities at the API boundary, which
  also affects the CDR entities and needs its own decision.
- docs frontmatter was missing the two files a whole section of the doc is about.
- A gofmt sweep had pulled three unrelated files into the diff. Reverted.
- A fixture used the real dev signature row id. Synthetic now.

Signed-off-by: Alon Liwsky <alon@armosec.io>
@AlonLiwsky
AlonLiwsky merged commit 81cae0e into main Aug 31, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted Created through Armosec AI tooling (armosec-shared-rules plugin)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants