Skip to content

Conversation

@adrienthebo
Copy link
Contributor

@adrienthebo adrienthebo commented Oct 19, 2025

Pull Request Checklist

  • The Rayhunter team has recently expressed interest in reviewing a PR for this. If not, this PR may be closed due our limited resources and need to prioritize how we spend them.
  • Added or updated any documentation as needed to support the changes in this PR.
  • Code has been linted and run through cargo fmt
  • If any new functionality has been added, unit tests were also added
    • No tests are present in ./check but I can add them if needed
  • ./CONTRIBUTING.md has been read

Problem

Rayhunter check output is emitted as plain text and doesn't lend itself to easy analysis by other tools.

Solution

Implement a --report flag, preserve the existing behavior as the default (manually invoked with --report log), and add an ndjson formatter.

Fixes #570.

Verification

Example output
❯ cargo run -- --path ../../rayhunter-traces/1749319445-goodsim.ndjson --report ndjson
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.07s
     Running `/Users/adrienthebo/personal/projects/radio/rayhunter-work/rayhunter/target/debug/rayhunter-check --path ../../rayhunter-traces/1749319445-goodsim.ndjson --report ndjson`
INFO  [rayhunter_check] Analyzers:
INFO  [rayhunter_check]     - Identity (IMSI or IMEI) requested in suspicious manner (v3): Tests whether the ME sends an Identity Request NAS message without either an associated attach request or auth accept message
INFO  [rayhunter_check]     - Connection Release/Redirected Carrier 2G Downgrade (v1): Tests if a cell releases our connection and redirects us to a 2G cell.
INFO  [rayhunter_check]     - LTE SIB 6/7 Downgrade (v1): Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.
INFO  [rayhunter_check]     - Null Cipher (v1): Tests whether the cell suggests using a null cipher (EEA0)
INFO  [rayhunter_check]     - NAS Null Cipher Requested (v1): Tests whether the MME requests to use a null cipher in the NAS security mode command
INFO  [rayhunter_check]     - Incomplete SIB (v2): Tests whether a SIB1 message contains a full chain of followup sibs

1749319445-goodsim.ndjson

{"analyzers":[{"name":"Identity (IMSI or IMEI) requested in suspicious manner","description":"Tests whether the ME sends an Identity Request NAS message without either an associated attach request or auth accept message","version":3},{"name":"Connection Release/Redirected Carrier 2G Downgrade","description":"Tests if a cell releases our connection and redirects us to a 2G cell.","version":1},{"name":"LTE SIB 6/7 Downgrade","description":"Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.","version":1},{"name":"Null Cipher","description":"Tests whether the cell suggests using a null cipher (EEA0)","version":1},{"name":"NAS Null Cipher Requested","description":"Tests whether the MME requests to use a null cipher in the NAS security mode command","version":1},{"name":"Incomplete SIB","description":"Tests whether a SIB1 message contains a full chain of followup sibs","version":2}],"rayhunter":{"rayhunter_version":"0.7.1","system_os":"Darwin 24.6.0","arch":"arm64"},"report_version":2}
{"packet_timestamp":"2025-06-07T21:03:24.743Z","events":[{"event_type":"Informational","message":"Identity Request happened but its not suspicious yet. (packet 12761)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-07T21:35:41.969Z","events":[{"event_type":"Informational","message":"Identity Request happened but its not suspicious yet. (packet 15242)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-07T22:10:20.991Z","events":[{"event_type":"Informational","message":"Identity Request happened but its not suspicious yet. (packet 18097)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-07T22:10:23.290Z","events":[{"event_type":"Informational","message":"Identity Request happened but its not suspicious yet. (packet 18138)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T03:30:56.706Z","events":[{"event_type":"Informational","message":"SIB1 scheduling info list was malformed (packet 30097)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T03:30:57.067Z","events":[{"event_type":"Informational","message":"SIB1 scheduling info list was malformed (packet 30098)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T03:30:59.228Z","events":[{"event_type":"Informational","message":"SIB1 scheduling info list was malformed (packet 30102)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T08:46:57.462Z","events":[{"event_type":"High","message":"Identity requested after auth request (packet 30765)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T08:46:58.205Z","events":[{"event_type":"High","message":"Disconnected after Identity Request without Auth Accept (packet 30769)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T09:22:13.650Z","events":[{"event_type":"High","message":"Identity requested after auth request (packet 32055)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T09:22:14.535Z","events":[{"event_type":"High","message":"Disconnected after Identity Request without Auth Accept (packet 32059)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T12:20:30.716Z","events":[{"event_type":"High","message":"Identity requested after auth request (packet 35219)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T12:20:31.447Z","events":[{"event_type":"High","message":"Disconnected after Identity Request without Auth Accept (packet 35224)"}],"skipped_message_reason":null}
{"packet_timestamp":"2025-06-08T21:22:34.877Z","events":[{"event_type":"High","message":"Identity requested after auth request (packet 121675)"}],"skipped_message_reason":null}

Considerations

  • The use of a dyn Trait feels a bit clumsy
  • Reconcile status logging
    • log macros now write to stderr
    • --report now writes a file so there's no issue of redirection writing extra logs.
  • Deal with multiple files

@adrienthebo adrienthebo force-pushed the alt/check/json-reporter branch 2 times, most recently from c2cb8cc to 524059d Compare October 20, 2025 03:59
return;
}

let events = row
Copy link
Contributor Author

@adrienthebo adrienthebo Oct 20, 2025

Choose a reason for hiding this comment

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

Note that this will compress out None values; the daemon/src/analysis.rs AnalysisWriter will not perform that compression. We could push this log into that writer as well if desired.

@adrienthebo adrienthebo force-pushed the alt/check/json-reporter branch from 524059d to b46ae62 Compare October 20, 2025 04:03
@adrienthebo
Copy link
Contributor Author

I'm pretty slammed with work right now, will follow up on this within the next week.

@untitaker
Copy link
Collaborator

so far it looks good to me. I am not worried about the use of dyn, ok to keep as is. I would recommend moving all logging to stderr though (unconditionally)

@adrienthebo adrienthebo force-pushed the alt/check/json-reporter branch from 7f8bdb1 to b881cc5 Compare October 27, 2025 04:12
@adrienthebo adrienthebo marked this pull request as ready for review October 27, 2025 04:26
@adrienthebo
Copy link
Contributor Author

This is ready for review; I haven't written rust in a few years and I'm unattached to this implementation so I'm happy to steer this change wherever needed.

Copy link
Collaborator

@untitaker untitaker left a comment

Choose a reason for hiding this comment

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

first wave of review, i think the direction is good but if the format is supposed to match then we should be able to share code.

writer: BufWriter<File>,
}

// The `njson` report has the same output format as the daemon analysis report.
Copy link
Collaborator

Choose a reason for hiding this comment

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

can it be made so that the structs are then actually shared between check and daemon? okay to move things into lib/

particularly, the definition of OutputRow and all code that converts to that structure

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Extracted a DetectionRow in ea6e73e. At present the daemon doesn't perform the same row collapsing so I'll add that logic in a separate PR to separate that behavior change.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll need to take another pass at this since the first pass of using TryFrom doesn't seem ideal.


[dependencies.simple_logger]
version = "5.0.0"
features = ["stderr"]
Copy link
Collaborator

Choose a reason for hiding this comment

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

can you document here why it has to be stderr?

Copy link
Contributor Author

@adrienthebo adrienthebo Oct 29, 2025

Choose a reason for hiding this comment

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

It's no longer strictly necessary to log to stderr; my original implementation printed json to stdout for simplicity but since --path supports both files and directories we have to write .ndjson files in order to have a coherent output that matches the daemon ndjson output.

My instinct is to revert this change and continue logging to stdout so that check ... | less will work as expected, but I don't have strong feelings either way. I'll document why we made this change if you want to continue with the switch to stderr.

Copy link
Collaborator

Choose a reason for hiding this comment

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

i think logging to stderr is still the right thing to do, just asking about documentation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Amended in b877ce4.

fn try_from(ar: AnalysisRow) -> Result<DetectionRow, Self::Error> {
let events: Vec<Event> = ar.events.into_iter().filter_map(|e| e).collect();

if events.is_empty() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This will break skip reason generation. TryFrom might be a poor fit here because we need to be able to return an output row with n events or a skip reason and TryFrom doesn't make this configurable without passing a tuple, and at that point it's starting to abuse the TryFrom semantics.

}
}

let det = DetectionRow::try_from(row).ok();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Needs to handle skip reasons.


// The `njson` report has the same output format as the daemon analysis report.
// See also: [Newline Delimited JSON](https://docs.mulesoft.com/dataweave/latest/dataweave-formats-ndjson)
impl NdjsonReport {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This could likely reuse the daemon AnalysisWriter struct. Since we're going to be refactoring to reuse components then extracting and reusing that struct will be worth the lift.

@untitaker untitaker marked this pull request as draft November 2, 2025 23:50
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.

enhance rayhunter-check output

2 participants