Skip to main content

fints/
audit.rs

1//! FinTS 3.0 protocol compliance auditing.
2//!
3//! Validates messages against FinTS 3.0 spec rules.
4//! Used by both client (to audit servers) and server (to audit client requests).
5
6use crate::parser::{parse_inner_segments, parse_message, RawSegment};
7use chrono::{DateTime, Utc};
8use serde_json::{json, Value};
9
10// ── Public types ─────────────────────────────────────────────────────────────
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum ViolationSeverity {
14    /// Clear spec violation.
15    Error,
16    /// Potentially non-compliant.
17    Warning,
18    /// Notable but not necessarily wrong.
19    Info,
20}
21
22impl ViolationSeverity {
23    fn as_str(&self) -> &'static str {
24        match self {
25            ViolationSeverity::Error => "ERROR",
26            ViolationSeverity::Warning => "WARNING",
27            ViolationSeverity::Info => "INFO",
28        }
29    }
30}
31
32#[derive(Debug, Clone)]
33pub struct Violation {
34    pub severity: ViolationSeverity,
35    /// Short rule ID, e.g. "HNHBK-001".
36    pub rule: String,
37    /// Human-readable explanation.
38    pub description: String,
39    /// Which segment triggered this (if applicable).
40    pub segment: Option<String>,
41}
42
43pub struct AuditReport {
44    pub violations: Vec<Violation>,
45    pub segments_checked: usize,
46    pub timestamp: DateTime<Utc>,
47}
48
49impl AuditReport {
50    pub fn has_errors(&self) -> bool {
51        self.violations
52            .iter()
53            .any(|v| v.severity == ViolationSeverity::Error)
54    }
55
56    pub fn error_count(&self) -> usize {
57        self.violations
58            .iter()
59            .filter(|v| v.severity == ViolationSeverity::Error)
60            .count()
61    }
62
63    pub fn warning_count(&self) -> usize {
64        self.violations
65            .iter()
66            .filter(|v| v.severity == ViolationSeverity::Warning)
67            .count()
68    }
69
70    /// Produce a human-readable audit report.
71    pub fn format_report(&self) -> String {
72        let mut out = String::new();
73        out.push_str(&format!(
74            "FinTS Audit Report — {} — {} segments checked\n",
75            self.timestamp.format("%Y-%m-%dT%H:%M:%SZ"),
76            self.segments_checked
77        ));
78        out.push_str(&format!(
79            "  Errors: {}  Warnings: {}  Total violations: {}\n",
80            self.error_count(),
81            self.warning_count(),
82            self.violations.len()
83        ));
84
85        if self.violations.is_empty() {
86            out.push_str("  No violations found.\n");
87        } else {
88            out.push_str("  Violations:\n");
89            for v in &self.violations {
90                let seg_part = v
91                    .segment
92                    .as_deref()
93                    .map(|s| format!(" [{}]", s))
94                    .unwrap_or_default();
95                out.push_str(&format!(
96                    "    [{:7}] {:12}{} — {}\n",
97                    v.severity.as_str(),
98                    v.rule,
99                    seg_part,
100                    v.description
101                ));
102            }
103        }
104        out
105    }
106
107    /// Produce a machine-readable JSON value.
108    pub fn to_json(&self) -> Value {
109        let violations: Vec<Value> = self
110            .violations
111            .iter()
112            .map(|v| {
113                json!({
114                    "severity": v.severity.as_str(),
115                    "rule": v.rule,
116                    "description": v.description,
117                    "segment": v.segment,
118                })
119            })
120            .collect();
121
122        json!({
123            "timestamp": self.timestamp.to_rfc3339(),
124            "segments_checked": self.segments_checked,
125            "error_count": self.error_count(),
126            "warning_count": self.warning_count(),
127            "violations": violations,
128        })
129    }
130}
131
132// ── Public audit functions ────────────────────────────────────────────────────
133
134/// Audit a client request message (sent by client to bank).
135pub fn audit_client_message(data: &[u8]) -> AuditReport {
136    let mut ctx = AuditContext::new();
137
138    let segments = match parse_message(data) {
139        Ok(s) => s,
140        Err(e) => {
141            ctx.add(
142                ViolationSeverity::Error,
143                "PARSE-001",
144                &format!("Message could not be parsed: {}", e),
145                None,
146            );
147            return ctx.finish(0);
148        }
149    };
150
151    let count = segments.len();
152    check_hnhbk_rules(&segments, &mut ctx);
153    check_hnvsk_rules(&segments, &mut ctx);
154    check_hnvsd_rules(&segments, &mut ctx, true);
155    check_hnhbs_rule(&segments, &mut ctx);
156
157    ctx.finish(count)
158}
159
160/// Audit a server response message (sent by bank to client).
161pub fn audit_server_response(data: &[u8]) -> AuditReport {
162    let mut ctx = AuditContext::new();
163
164    let segments = match parse_message(data) {
165        Ok(s) => s,
166        Err(e) => {
167            ctx.add(
168                ViolationSeverity::Error,
169                "PARSE-001",
170                &format!("Message could not be parsed: {}", e),
171                None,
172            );
173            return ctx.finish(0);
174        }
175    };
176
177    let count = segments.len();
178    check_hnhbk_rules(&segments, &mut ctx);
179    check_hnvsk_rules(&segments, &mut ctx);
180    check_hnvsd_rules(&segments, &mut ctx, false);
181    check_hirmg_rules(&segments, &mut ctx);
182    check_response_code_rules(&segments, &mut ctx);
183    check_hnhbs_rule(&segments, &mut ctx);
184
185    ctx.finish(count)
186}
187
188// ── Internal audit logic ──────────────────────────────────────────────────────
189
190struct AuditContext {
191    violations: Vec<Violation>,
192}
193
194impl AuditContext {
195    fn new() -> Self {
196        AuditContext {
197            violations: Vec::new(),
198        }
199    }
200
201    fn add(
202        &mut self,
203        severity: ViolationSeverity,
204        rule: &str,
205        description: &str,
206        segment: Option<&str>,
207    ) {
208        self.violations.push(Violation {
209            severity,
210            rule: rule.to_string(),
211            description: description.to_string(),
212            segment: segment.map(str::to_string),
213        });
214    }
215
216    fn finish(self, segments_checked: usize) -> AuditReport {
217        AuditReport {
218            violations: self.violations,
219            segments_checked,
220            timestamp: Utc::now(),
221        }
222    }
223}
224
225/// HNHBK-001..004
226fn check_hnhbk_rules(segments: &[RawSegment], ctx: &mut AuditContext) {
227    // HNHBK-001: Must have exactly one HNHBK, and it must be first.
228    let hnhbk_count = segments
229        .iter()
230        .filter(|s| s.segment_type() == "HNHBK")
231        .count();
232    if hnhbk_count == 0 {
233        ctx.add(
234            ViolationSeverity::Error,
235            "HNHBK-001",
236            "Message has no HNHBK segment (must be first)",
237            Some("HNHBK"),
238        );
239        return; // remaining HNHBK checks are moot
240    }
241    if hnhbk_count > 1 {
242        ctx.add(
243            ViolationSeverity::Error,
244            "HNHBK-001",
245            "Message has more than one HNHBK segment",
246            Some("HNHBK"),
247        );
248    }
249    if segments[0].segment_type() != "HNHBK" {
250        ctx.add(
251            ViolationSeverity::Error,
252            "HNHBK-001",
253            "HNHBK is not the first segment",
254            Some("HNHBK"),
255        );
256    }
257
258    let hnhbk = &segments[0];
259
260    // HNHBK-002: Size field must be >= 50.
261    let size_str = hnhbk.deg(1).get_str(0);
262    match size_str.parse::<u64>() {
263        Ok(size) if size < 50 => {
264            ctx.add(
265                ViolationSeverity::Error,
266                "HNHBK-002",
267                &format!("HNHBK size field is {} (must be >= 50)", size),
268                Some("HNHBK"),
269            );
270        }
271        Err(_) => {
272            ctx.add(
273                ViolationSeverity::Error,
274                "HNHBK-002",
275                &format!("HNHBK size field is not a valid number: {:?}", size_str),
276                Some("HNHBK"),
277            );
278        }
279        _ => {}
280    }
281
282    // HNHBK-003: FinTS version must be "300".
283    let version = hnhbk.deg(3).get_str(0);
284    if version != "300" {
285        ctx.add(
286            ViolationSeverity::Error,
287            "HNHBK-003",
288            &format!("FinTS version is {:?} (expected \"300\")", version),
289            Some("HNHBK"),
290        );
291    }
292
293    // HNHBK-004: Message number must be >= 1.
294    let msg_num_str = hnhbk.deg(4).get_str(0);
295    match msg_num_str.parse::<u64>() {
296        Ok(n) if n < 1 => {
297            ctx.add(
298                ViolationSeverity::Error,
299                "HNHBK-004",
300                &format!("HNHBK message number is {} (must be >= 1)", n),
301                Some("HNHBK"),
302            );
303        }
304        Err(_) => {
305            ctx.add(
306                ViolationSeverity::Warning,
307                "HNHBK-004",
308                &format!(
309                    "HNHBK message number is not a valid number: {:?}",
310                    msg_num_str
311                ),
312                Some("HNHBK"),
313            );
314        }
315        _ => {}
316    }
317}
318
319/// HNVSK-001: Must have HNVSK at segment number 998.
320fn check_hnvsk_rules(segments: &[RawSegment], ctx: &mut AuditContext) {
321    let hnvsk = segments.iter().find(|s| s.segment_type() == "HNVSK");
322    match hnvsk {
323        None => {
324            ctx.add(
325                ViolationSeverity::Error,
326                "HNVSK-001",
327                "No HNVSK segment found",
328                Some("HNVSK"),
329            );
330        }
331        Some(seg) if seg.segment_number() != 998 => {
332            ctx.add(
333                ViolationSeverity::Error,
334                "HNVSK-001",
335                &format!(
336                    "HNVSK segment number is {} (must be 998)",
337                    seg.segment_number()
338                ),
339                Some("HNVSK"),
340            );
341        }
342        _ => {}
343    }
344}
345
346/// HNVSD-001, HNVSD-002, and inner segment rules.
347fn check_hnvsd_rules(segments: &[RawSegment], ctx: &mut AuditContext, is_client: bool) {
348    let hnvsd = segments.iter().find(|s| s.segment_type() == "HNVSD");
349    match hnvsd {
350        None => {
351            ctx.add(
352                ViolationSeverity::Error,
353                "HNVSD-001",
354                "No HNVSD segment found",
355                Some("HNVSD"),
356            );
357            return;
358        }
359        Some(seg) if seg.segment_number() != 999 => {
360            ctx.add(
361                ViolationSeverity::Error,
362                "HNVSD-001",
363                &format!(
364                    "HNVSD segment number is {} (must be 999)",
365                    seg.segment_number()
366                ),
367                Some("HNVSD"),
368            );
369        }
370        _ => {}
371    }
372
373    // HNVSD-002: payload must be parseable.
374    let hnvsd = hnvsd.unwrap();
375    let payload_opt = hnvsd.deg(1).get(0).as_bytes().map(|b| b.to_vec());
376    match payload_opt {
377        None => {
378            ctx.add(
379                ViolationSeverity::Warning,
380                "HNVSD-002",
381                "HNVSD payload data element is not binary",
382                Some("HNVSD"),
383            );
384        }
385        Some(payload) => match parse_inner_segments(&payload) {
386            Err(e) => {
387                ctx.add(
388                    ViolationSeverity::Error,
389                    "HNVSD-002",
390                    &format!("HNVSD payload is not parseable: {}", e),
391                    Some("HNVSD"),
392                );
393            }
394            Ok(inner) => {
395                if is_client {
396                    check_inner_client_rules(&inner, ctx);
397                }
398            }
399        },
400    }
401}
402
403/// HNSHK-001, HNSHA-001, HKIDN-001, HKVVB-001 (inner segment checks for client messages).
404fn check_inner_client_rules(inner: &[RawSegment], ctx: &mut AuditContext) {
405    let has_hnshk = inner.iter().any(|s| s.segment_type() == "HNSHK");
406    if !has_hnshk {
407        ctx.add(
408            ViolationSeverity::Error,
409            "HNSHK-001",
410            "Inner segments (HNVSD payload) do not contain HNSHK",
411            Some("HNSHK"),
412        );
413    }
414
415    let has_hnsha = inner.iter().any(|s| s.segment_type() == "HNSHA");
416    if !has_hnsha {
417        ctx.add(
418            ViolationSeverity::Error,
419            "HNSHA-001",
420            "Inner segments (HNVSD payload) do not contain HNSHA",
421            Some("HNSHA"),
422        );
423    }
424
425    let has_hkidn = inner.iter().any(|s| s.segment_type() == "HKIDN");
426    if !has_hkidn {
427        ctx.add(
428            ViolationSeverity::Error,
429            "HKIDN-001",
430            "Inner segments (HNVSD payload) do not contain HKIDN",
431            Some("HKIDN"),
432        );
433    }
434
435    let has_hkvvb = inner.iter().any(|s| s.segment_type() == "HKVVB");
436    if !has_hkvvb {
437        ctx.add(
438            ViolationSeverity::Error,
439            "HKVVB-001",
440            "Inner segments (HNVSD payload) do not contain HKVVB",
441            Some("HKVVB"),
442        );
443    }
444}
445
446/// HNHBS-001: Last segment must be HNHBS.
447fn check_hnhbs_rule(segments: &[RawSegment], ctx: &mut AuditContext) {
448    match segments.last() {
449        None => {
450            ctx.add(
451                ViolationSeverity::Error,
452                "HNHBS-001",
453                "Message has no segments",
454                None,
455            );
456        }
457        Some(last) if last.segment_type() != "HNHBS" => {
458            ctx.add(
459                ViolationSeverity::Error,
460                "HNHBS-001",
461                &format!("Last segment is {:?} (must be HNHBS)", last.segment_type()),
462                Some("HNHBS"),
463            );
464        }
465        _ => {}
466    }
467}
468
469/// HIRMG-001, HIRMG-002.
470fn check_hirmg_rules(segments: &[RawSegment], ctx: &mut AuditContext) {
471    let hirmg_count = segments
472        .iter()
473        .filter(|s| s.segment_type() == "HIRMG")
474        .count();
475
476    if hirmg_count == 0 {
477        ctx.add(
478            ViolationSeverity::Error,
479            "HIRMG-001",
480            "Server response has no HIRMG segment",
481            Some("HIRMG"),
482        );
483        return;
484    }
485
486    // HIRMG-002: Must have at least one response code.
487    for seg in segments.iter().filter(|s| s.segment_type() == "HIRMG") {
488        let data_degs: Vec<_> = seg.degs.iter().skip(1).collect();
489        if data_degs.is_empty() {
490            ctx.add(
491                ViolationSeverity::Error,
492                "HIRMG-002",
493                "HIRMG has no response code DEGs",
494                Some("HIRMG"),
495            );
496        }
497    }
498}
499
500/// RESP-001, RESP-002: response code format rules.
501fn check_response_code_rules(segments: &[RawSegment], ctx: &mut AuditContext) {
502    let code_segs: Vec<_> = segments
503        .iter()
504        .filter(|s| s.segment_type() == "HIRMG" || s.segment_type() == "HIRMS")
505        .collect();
506
507    for seg in &code_segs {
508        let is_global = seg.segment_type() == "HIRMG";
509        let mut success_seen = false;
510        let mut error_seen = false;
511
512        for deg in seg.degs.iter().skip(1) {
513            let code = deg.get_str(0);
514            if code.is_empty() {
515                continue;
516            }
517
518            // RESP-001: Code must be exactly 4 digits.
519            if code.len() != 4 || !code.chars().all(|c| c.is_ascii_digit()) {
520                ctx.add(
521                    ViolationSeverity::Error,
522                    "RESP-001",
523                    &format!("Response code {:?} is not 4 digits", code),
524                    Some(seg.segment_type()),
525                );
526            }
527
528            // Track success vs error for RESP-002.
529            if let Ok(n) = code.parse::<u16>() {
530                if n < 3000 {
531                    success_seen = true;
532                } else if n >= 9000 {
533                    error_seen = true;
534                }
535            }
536        }
537
538        // RESP-002: Must not have both success and error codes at global level.
539        if is_global && success_seen && error_seen {
540            ctx.add(
541                ViolationSeverity::Warning,
542                "RESP-002",
543                "HIRMG contains both success (< 3000) and error (>= 9000) codes",
544                Some("HIRMG"),
545            );
546        }
547    }
548}
549
550// ── Tests ─────────────────────────────────────────────────────────────────────
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    /// Minimal valid outer wrapper without inner segments.
557    fn minimal_outer(inner_payload: &[u8]) -> Vec<u8> {
558        // HNHBK with size ~100, version 300, dialog 0, msg 1
559        // HNVSK at seg 998
560        // HNVSD at seg 999 with binary payload
561        // HNHBS as last
562        let payload_len = inner_payload.len();
563        let hnvsd_part = format!("HNVSD:999:1+@{}@", payload_len);
564        let mut msg = Vec::new();
565        msg.extend_from_slice(b"HNHBK:1:3+000000000100+300+0+1+1'");
566        msg.extend_from_slice(b"HNVSK:998:3+998+1+1::0+1:20200101:120000+2:2:13:@8@00000000:5:1+280:12345678:user:V:0:0+0'");
567        msg.extend_from_slice(hnvsd_part.as_bytes());
568        msg.extend_from_slice(inner_payload);
569        msg.push(b'\'');
570        msg.extend_from_slice(b"HNHBS:6:1+1'");
571        msg
572    }
573
574    #[test]
575    fn test_audit_client_no_hnhbk() {
576        let data = b"HNHBS:5:1+1'";
577        let report = audit_client_message(data);
578        let rules: Vec<_> = report.violations.iter().map(|v| v.rule.as_str()).collect();
579        assert!(rules.contains(&"HNHBK-001"), "Expected HNHBK-001 violation");
580    }
581
582    #[test]
583    fn test_audit_report_has_errors_and_warnings() {
584        let data = b"HNHBS:5:1+1'";
585        let report = audit_client_message(data);
586        assert!(report.has_errors());
587        assert!(report.error_count() > 0);
588    }
589
590    #[test]
591    fn test_audit_report_format_contains_rule() {
592        let data = b"HNHBS:5:1+1'";
593        let report = audit_client_message(data);
594        let formatted = report.format_report();
595        assert!(formatted.contains("HNHBK-001"));
596    }
597
598    #[test]
599    fn test_audit_report_to_json() {
600        let data = b"HNHBS:5:1+1'";
601        let report = audit_client_message(data);
602        let json = report.to_json();
603        assert!(json["violations"].is_array());
604        assert!(json["error_count"].as_u64().unwrap() > 0);
605    }
606
607    #[test]
608    fn test_audit_server_response_missing_hirmg() {
609        // A message with proper HNHBK but no HIRMG.
610        let data =
611            b"HNHBK:1:3+000000000100+300+0+1+1'HNVSK:998:3+1'HNVSD:999:1+@6@TEST:1'HNHBS:6:1+1'";
612        let report = audit_server_response(data);
613        let rules: Vec<_> = report.violations.iter().map(|v| v.rule.as_str()).collect();
614        assert!(rules.contains(&"HIRMG-001"), "Expected HIRMG-001 violation");
615    }
616
617    #[test]
618    fn test_audit_hnhbs_must_be_last() {
619        // HNHBS in the middle, not last.
620        let data = b"HNHBK:1:3+000000000100+300+0+1+1'HNHBS:3:1+1'HIRMG:4:2+0010::OK.'";
621        let report = audit_server_response(data);
622        let rules: Vec<_> = report.violations.iter().map(|v| v.rule.as_str()).collect();
623        assert!(rules.contains(&"HNHBS-001"), "Expected HNHBS-001 violation");
624    }
625
626    #[test]
627    fn test_audit_resp001_bad_code() {
628        let data = b"HNHBK:1:3+000000000100+300+0+1+1'HIRMG:3:2+XYZ::Bad code.'HNHBS:4:1+1'";
629        let report = audit_server_response(data);
630        let rules: Vec<_> = report.violations.iter().map(|v| v.rule.as_str()).collect();
631        assert!(rules.contains(&"RESP-001"), "Expected RESP-001 violation");
632    }
633
634    #[test]
635    fn test_hnhbk_version_rule() {
636        // FinTS version 200 instead of 300.
637        let data = b"HNHBK:1:3+000000000100+200+0+1+1'HNHBS:2:1+1'";
638        let report = audit_client_message(data);
639        let rules: Vec<_> = report.violations.iter().map(|v| v.rule.as_str()).collect();
640        assert!(rules.contains(&"HNHBK-003"), "Expected HNHBK-003 violation");
641    }
642
643    #[test]
644    fn test_violation_severity_as_str() {
645        assert_eq!(ViolationSeverity::Error.as_str(), "ERROR");
646        assert_eq!(ViolationSeverity::Warning.as_str(), "WARNING");
647        assert_eq!(ViolationSeverity::Info.as_str(), "INFO");
648    }
649}