1use crate::audit::{AuditContext, AuditRule, FnScan};
4use crate::types::{AuditReport, Finding, Severity};
5
6pub struct Auth001;
8
9impl AuditRule for Auth001 {
10 fn id(&self) -> &'static str {
11 "AUTH-001"
12 }
13 fn severity(&self) -> Severity {
14 Severity::Critical
15 }
16 fn description(&self) -> &'static str {
17 "Privileged/admin function must call require_auth()"
18 }
19 fn check(&self, scans: &[FnScan], _ctx: &AuditContext, report: &mut AuditReport) {
20 for s in scans {
21 if crate::audit::is_privileged(&s.fn_name) && s.require_auth == 0 {
22 report.add(Finding {
23 file: None,
24 rule_id: self.id().to_string(),
25 severity: self.severity(),
26 message: format!(
27 "Function `{}` looks privileged but does not call require_auth()",
28 s.fn_name
29 ),
30 location: Some(s.fn_name.clone()),
31 });
32 }
33 }
34 }
35}
36
37pub struct Auth002;
39
40impl AuditRule for Auth002 {
41 fn id(&self) -> &'static str {
42 "AUTH-002"
43 }
44 fn severity(&self) -> Severity {
45 Severity::Critical
46 }
47 fn description(&self) -> &'static str {
48 "Cross-contract invoke_contract() must be guarded by require_auth()"
49 }
50 fn check(&self, scans: &[FnScan], _ctx: &AuditContext, report: &mut AuditReport) {
51 for s in scans {
52 if s.invoke_contract > 0 && s.require_auth == 0 {
53 report.add(Finding {
54 file: None,
55 rule_id: self.id().to_string(),
56 severity: self.severity(),
57 message: format!(
58 "Function `{}` calls invoke_contract() without require_auth()",
59 s.fn_name
60 ),
61 location: Some(s.fn_name.clone()),
62 });
63 }
64 }
65 }
66}
67
68pub struct Auth003;
70
71impl AuditRule for Auth003 {
72 fn id(&self) -> &'static str {
73 "AUTH-003"
74 }
75 fn severity(&self) -> Severity {
76 Severity::Critical
77 }
78 fn description(&self) -> &'static str {
79 "initialize()-style entrypoint must call require_auth()"
80 }
81 fn check(&self, scans: &[FnScan], ctx: &AuditContext, report: &mut AuditReport) {
82 for s in scans {
83 let is_init = crate::audit::is_initialize(&s.fn_name);
84 if !is_init || s.require_auth > 0 {
85 continue;
86 }
87 let exported = match ctx.spec {
91 Some(spec) => spec.functions.iter().any(|f| {
92 f.name
93 .eq_ignore_ascii_case(crate::audit::unqualified(&s.fn_name))
94 }),
95 None => true,
96 };
97 if exported {
98 report.add(Finding {
99 file: None,
100 rule_id: self.id().to_string(),
101 severity: self.severity(),
102 message: format!(
103 "initialize-style function `{}` has no require_auth() guard",
104 s.fn_name
105 ),
106 location: Some(s.fn_name.clone()),
107 });
108 }
109 }
110 }
111}
112
113pub struct Auth004;
115
116impl AuditRule for Auth004 {
117 fn id(&self) -> &'static str {
118 "AUTH-004"
119 }
120 fn severity(&self) -> Severity {
121 Severity::Critical
122 }
123 fn description(&self) -> &'static str {
124 "Token transfer function must call require_auth() on source or destination"
125 }
126 fn check(&self, scans: &[FnScan], _ctx: &AuditContext, report: &mut AuditReport) {
127 for s in scans {
128 let name = s.fn_name.to_lowercase();
129 let is_transfer = matches!(
130 name.as_str(),
131 "transfer" | "transfer_from" | "transferfrom" | "withdraw" | "burn"
132 );
133 if is_transfer && s.require_auth == 0 {
134 report.add(Finding {
135 file: None,
136 rule_id: self.id().to_string(),
137 severity: self.severity(),
138 message: format!(
139 "Transfer-style function `{}` does not call require_auth()",
140 s.fn_name
141 ),
142 location: Some(s.fn_name.clone()),
143 });
144 }
145 }
146 }
147}
148
149pub struct Move001;
151
152impl AuditRule for Move001 {
153 fn id(&self) -> &'static str {
154 "MOVE-001"
155 }
156 fn severity(&self) -> Severity {
157 Severity::Warning
158 }
159 fn description(&self) -> &'static str {
160 "Local used as a call argument multiple times (possible move-after-use)"
161 }
162 fn check(&self, scans: &[FnScan], _ctx: &AuditContext, report: &mut AuditReport) {
163 for s in scans {
164 for (name, count) in &s.usage {
165 if *count >= 2 {
166 report.add(Finding {
167 file: None,
168 rule_id: self.id().to_string(),
169 severity: self.severity(),
170 message: format!(
171 "Local `{}` in `{}` is used as a call argument {} times — possible move-after-use",
172 name, s.fn_name, count
173 ),
174 location: Some(format!("{}:{}", s.fn_name, name)),
175 });
176 }
177 }
178 }
179 }
180}
181
182pub struct Math001;
184
185impl AuditRule for Math001 {
186 fn id(&self) -> &'static str {
187 "MATH-001"
188 }
189 fn severity(&self) -> Severity {
190 Severity::Warning
191 }
192 fn description(&self) -> &'static str {
193 "Division before multiplication may truncate numeric precision"
194 }
195 fn check(&self, scans: &[FnScan], _ctx: &AuditContext, report: &mut AuditReport) {
196 for scan in scans {
197 if scan.division_before_multiplication {
198 report.add(Finding {
199 file: None,
200 rule_id: self.id().to_string(),
201 severity: self.severity(),
202 message: format!(
203 "Function `{}` divides before multiplying; division may truncate precision",
204 scan.fn_name
205 ),
206 location: Some(scan.fn_name.clone()),
207 });
208 }
209 }
210 }
211}
212
213pub struct Cei001;
268
269impl AuditRule for Cei001 {
270 fn id(&self) -> &'static str {
271 "CEI-001"
272 }
273 fn severity(&self) -> Severity {
274 Severity::Warning
275 }
276 fn description(&self) -> &'static str {
277 "External invocation before state write — reordering may prevent reentrancy"
278 }
279 fn check(&self, scans: &[FnScan], _ctx: &AuditContext, report: &mut AuditReport) {
280 for scan in scans {
281 if scan.external_call_before_state_write {
282 report.add(Finding {
283 file: None,
284 rule_id: self.id().to_string(),
285 severity: self.severity(),
286 message: format!(
287 "Function `{}` calls an external contract before writing state — \
288 reorder to Checks → Effects → Interactions to prevent reentrancy",
289 scan.fn_name
290 ),
291 location: Some(scan.fn_name.clone()),
292 });
293 }
294 }
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::types::AuditReport;
302
303 fn has(report: &AuditReport, rule_id: &str) -> bool {
304 report.findings.iter().any(|f| f.rule_id == rule_id)
305 }
306
307 #[test]
308 fn auth001_flags_mint_without_auth() {
309 let scans = vec![FnScan {
310 fn_name: "mint_token".into(),
311 require_auth: 0,
312 invoke_contract: 0,
313 bound: Default::default(),
314 usage: Default::default(),
315 division_before_multiplication: false,
316 external_call_before_state_write: false,
317 }];
318 let mut rep = AuditReport::default();
319 Auth001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
320 assert!(
321 has(&rep, "AUTH-001"),
322 "mint_token without auth should trigger AUTH-001"
323 );
324 }
325
326 #[test]
327 fn auth001_flags_initialize_without_auth() {
328 let scans = vec![FnScan {
329 fn_name: "initialize".into(),
330 require_auth: 0,
331 invoke_contract: 0,
332 bound: Default::default(),
333 usage: Default::default(),
334 division_before_multiplication: false,
335 external_call_before_state_write: false,
336 }];
337 let mut rep = AuditReport::default();
338 Auth001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
339 assert!(
340 has(&rep, "AUTH-001"),
341 "initialize without auth should trigger AUTH-001"
342 );
343 }
344
345 #[test]
346 fn auth001_does_not_flag_non_privileged() {
347 let scans = vec![FnScan {
348 fn_name: "balance_of".into(),
349 require_auth: 0,
350 invoke_contract: 0,
351 bound: Default::default(),
352 usage: Default::default(),
353 division_before_multiplication: false,
354 external_call_before_state_write: false,
355 }];
356 let mut rep = AuditReport::default();
357 Auth001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
358 assert!(
359 !has(&rep, "AUTH-001"),
360 "balance_of should not trigger AUTH-001"
361 );
362 }
363
364 #[test]
365 fn auth001_does_not_flag_privileged_with_auth() {
366 let scans = vec![FnScan {
367 fn_name: "mint_token".into(),
368 require_auth: 1,
369 invoke_contract: 0,
370 bound: Default::default(),
371 usage: Default::default(),
372 division_before_multiplication: false,
373 external_call_before_state_write: false,
374 }];
375 let mut rep = AuditReport::default();
376 Auth001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
377 assert!(
378 !has(&rep, "AUTH-001"),
379 "mint_token with auth should not trigger AUTH-001"
380 );
381 }
382
383 #[test]
384 fn auth002_flags_invoke_contract_without_auth() {
385 let scans = vec![FnScan {
386 fn_name: "cross_call".into(),
387 require_auth: 0,
388 invoke_contract: 1,
389 bound: Default::default(),
390 usage: Default::default(),
391 division_before_multiplication: false,
392 external_call_before_state_write: false,
393 }];
394 let mut rep = AuditReport::default();
395 Auth002.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
396 assert!(
397 has(&rep, "AUTH-002"),
398 "invoke_contract without auth should trigger AUTH-002"
399 );
400 }
401
402 #[test]
403 fn auth002_does_not_flag_invoke_contract_with_auth() {
404 let scans = vec![FnScan {
405 fn_name: "cross_call".into(),
406 require_auth: 1,
407 invoke_contract: 1,
408 bound: Default::default(),
409 usage: Default::default(),
410 division_before_multiplication: false,
411 external_call_before_state_write: false,
412 }];
413 let mut rep = AuditReport::default();
414 Auth002.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
415 assert!(
416 !has(&rep, "AUTH-002"),
417 "invoke_contract with auth should not trigger AUTH-002"
418 );
419 }
420
421 #[test]
422 fn auth002_does_not_flag_without_invoke_contract() {
423 let scans = vec![FnScan {
424 fn_name: "local_call".into(),
425 require_auth: 0,
426 invoke_contract: 0,
427 bound: Default::default(),
428 usage: Default::default(),
429 division_before_multiplication: false,
430 external_call_before_state_write: false,
431 }];
432 let mut rep = AuditReport::default();
433 Auth002.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
434 assert!(
435 !has(&rep, "AUTH-002"),
436 "no invoke_contract should not trigger AUTH-002"
437 );
438 }
439
440 #[test]
441 fn auth003_flags_initialize_without_auth() {
442 let scans = vec![FnScan {
443 fn_name: "initialize".into(),
444 require_auth: 0,
445 invoke_contract: 0,
446 bound: Default::default(),
447 usage: Default::default(),
448 division_before_multiplication: false,
449 external_call_before_state_write: false,
450 }];
451 let mut rep = AuditReport::default();
452 Auth003.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
453 assert!(
454 has(&rep, "AUTH-003"),
455 "initialize without auth should trigger AUTH-003"
456 );
457 }
458
459 #[test]
460 fn auth003_flags_init_token_without_auth() {
461 let scans = vec![FnScan {
462 fn_name: "init_token".into(),
463 require_auth: 0,
464 invoke_contract: 0,
465 bound: Default::default(),
466 usage: Default::default(),
467 division_before_multiplication: false,
468 external_call_before_state_write: false,
469 }];
470 let mut rep = AuditReport::default();
471 Auth003.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
472 assert!(
473 has(&rep, "AUTH-003"),
474 "init_token without auth should trigger AUTH-003"
475 );
476 }
477
478 #[test]
479 fn auth003_does_not_flag_initialize_with_auth() {
480 let scans = vec![FnScan {
481 fn_name: "initialize".into(),
482 require_auth: 1,
483 invoke_contract: 0,
484 bound: Default::default(),
485 usage: Default::default(),
486 division_before_multiplication: false,
487 external_call_before_state_write: false,
488 }];
489 let mut rep = AuditReport::default();
490 Auth003.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
491 assert!(
492 !has(&rep, "AUTH-003"),
493 "initialize with auth should not trigger AUTH-003"
494 );
495 }
496
497 #[test]
498 fn auth003_does_not_flag_non_initialize() {
499 let scans = vec![FnScan {
500 fn_name: "transfer".into(),
501 require_auth: 0,
502 invoke_contract: 0,
503 bound: Default::default(),
504 usage: Default::default(),
505 division_before_multiplication: false,
506 external_call_before_state_write: false,
507 }];
508 let mut rep = AuditReport::default();
509 Auth003.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
510 assert!(
511 !has(&rep, "AUTH-003"),
512 "non-initialize function should not trigger AUTH-003"
513 );
514 }
515
516 #[test]
517 fn move001_flags_reused_local() {
518 use std::collections::HashMap;
519 let mut usage = HashMap::new();
520 usage.insert("amount".to_string(), 2);
521 let scans = vec![FnScan {
522 fn_name: "do_swap".into(),
523 require_auth: 0,
524 invoke_contract: 0,
525 bound: Default::default(),
526 usage,
527 division_before_multiplication: false,
528 external_call_before_state_write: false,
529 }];
530 let mut rep = AuditReport::default();
531 Move001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
532 assert!(
533 has(&rep, "MOVE-001"),
534 "local used 2x as arg should trigger MOVE-001"
535 );
536 }
537
538 #[test]
539 fn move001_does_not_flag_single_use() {
540 use std::collections::HashMap;
541 let mut usage = HashMap::new();
542 usage.insert("amount".to_string(), 1);
543 let scans = vec![FnScan {
544 fn_name: "do_swap".into(),
545 require_auth: 0,
546 invoke_contract: 0,
547 bound: Default::default(),
548 usage,
549 division_before_multiplication: false,
550 external_call_before_state_write: false,
551 }];
552 let mut rep = AuditReport::default();
553 Move001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
554 assert!(
555 !has(&rep, "MOVE-001"),
556 "local used once should not trigger MOVE-001"
557 );
558 }
559
560 #[test]
561 fn move001_does_not_flag_no_usage() {
562 let scans = vec![FnScan {
563 fn_name: "do_nothing".into(),
564 require_auth: 0,
565 invoke_contract: 0,
566 bound: Default::default(),
567 usage: Default::default(),
568 division_before_multiplication: false,
569 external_call_before_state_write: false,
570 }];
571 let mut rep = AuditReport::default();
572 Move001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
573 assert!(
574 !has(&rep, "MOVE-001"),
575 "no usage should not trigger MOVE-001"
576 );
577 }
578 #[test]
579 fn auth004_flags_transfer_from_without_auth() {
580 let scans = vec![FnScan {
581 fn_name: "transfer_from".into(),
582 require_auth: 0,
583 invoke_contract: 0,
584 bound: Default::default(),
585 usage: Default::default(),
586 division_before_multiplication: false,
587 external_call_before_state_write: false,
588 }];
589 let mut rep = AuditReport::default();
590 Auth004.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
591 assert!(
592 has(&rep, "AUTH-004"),
593 "transfer_from without auth should trigger AUTH-004"
594 );
595 }
596
597 #[test]
598 fn auth004_does_not_flag_transfer_with_auth() {
599 let scans = vec![FnScan {
600 fn_name: "transfer".into(),
601 require_auth: 1,
602 invoke_contract: 0,
603 bound: Default::default(),
604 usage: Default::default(),
605 division_before_multiplication: false,
606 external_call_before_state_write: false,
607 }];
608 let mut rep = AuditReport::default();
609 Auth004.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
610 assert!(
611 !has(&rep, "AUTH-004"),
612 "transfer with auth should not trigger AUTH-004"
613 );
614 }
615
616 #[test]
617 fn auth004_does_not_flag_non_transfer() {
618 let scans = vec![FnScan {
619 fn_name: "balance_of".into(),
620 require_auth: 0,
621 invoke_contract: 0,
622 bound: Default::default(),
623 usage: Default::default(),
624 division_before_multiplication: false,
625 external_call_before_state_write: false,
626 }];
627 let mut rep = AuditReport::default();
628 Auth004.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
629 assert!(
630 !has(&rep, "AUTH-004"),
631 "non-transfer function should not trigger AUTH-004"
632 );
633 }
634
635 #[test]
636 fn auth004_does_not_flag_transfer_ownership() {
637 let scans = vec![FnScan {
640 fn_name: "transfer_ownership".into(),
641 require_auth: 0,
642 invoke_contract: 0,
643 bound: Default::default(),
644 usage: Default::default(),
645 division_before_multiplication: false,
646 external_call_before_state_write: false,
647 }];
648 let mut rep = AuditReport::default();
649 Auth004.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
650 assert!(
651 !has(&rep, "AUTH-004"),
652 "transfer_ownership should not trigger AUTH-004"
653 );
654 }
655
656 #[test]
657 fn auth004_does_not_flag_admin_transfer() {
658 let scans = vec![FnScan {
659 fn_name: "admin_transfer".into(),
660 require_auth: 0,
661 invoke_contract: 0,
662 bound: Default::default(),
663 usage: Default::default(),
664 division_before_multiplication: false,
665 external_call_before_state_write: false,
666 }];
667 let mut rep = AuditReport::default();
668 Auth004.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
669 assert!(
670 !has(&rep, "AUTH-004"),
671 "admin_transfer should not trigger AUTH-004"
672 );
673 }
674
675 #[test]
676 fn cei001_flags_external_call_before_state_write() {
677 let scans = vec![FnScan {
678 fn_name: "swap".into(),
679 require_auth: 0,
680 invoke_contract: 1,
681 bound: Default::default(),
682 usage: Default::default(),
683 division_before_multiplication: false,
684 external_call_before_state_write: true,
685 }];
686 let mut rep = AuditReport::default();
687 Cei001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
688 assert!(
689 has(&rep, "CEI-001"),
690 "external call before state write should trigger CEI-001"
691 );
692 }
693
694 #[test]
695 fn cei001_does_not_flag_state_write_before_external_call() {
696 let scans = vec![FnScan {
697 fn_name: "swap_safe".into(),
698 require_auth: 0,
699 invoke_contract: 1,
700 bound: Default::default(),
701 usage: Default::default(),
702 division_before_multiplication: false,
703 external_call_before_state_write: false,
704 }];
705 let mut rep = AuditReport::default();
706 Cei001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
707 assert!(
708 !has(&rep, "CEI-001"),
709 "state write before external call should not trigger CEI-001"
710 );
711 }
712
713 #[test]
714 fn cei001_does_not_flag_only_external_call() {
715 let scans = vec![FnScan {
716 fn_name: "relay".into(),
717 require_auth: 0,
718 invoke_contract: 1,
719 bound: Default::default(),
720 usage: Default::default(),
721 division_before_multiplication: false,
722 external_call_before_state_write: false,
723 }];
724 let mut rep = AuditReport::default();
725 Cei001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
726 assert!(
727 !has(&rep, "CEI-001"),
728 "only external call should not trigger CEI-001"
729 );
730 }
731
732 #[test]
733 fn cei001_does_not_flag_only_state_write() {
734 let scans = vec![FnScan {
735 fn_name: "set_state".into(),
736 require_auth: 0,
737 invoke_contract: 0,
738 bound: Default::default(),
739 usage: Default::default(),
740 division_before_multiplication: false,
741 external_call_before_state_write: false,
742 }];
743 let mut rep = AuditReport::default();
744 Cei001.check(&scans, &crate::audit::AuditContext { spec: None }, &mut rep);
745 assert!(
746 !has(&rep, "CEI-001"),
747 "only state write should not trigger CEI-001"
748 );
749 }
750}