1use crate::source::SourceSpan;
4
5use super::Located;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum SecurityOptionKind {
11 AppArmor {
13 profile: String,
15 },
16 Seccomp {
18 profile: String,
20 },
21 NoNewPrivileges {
23 enabled: bool,
25 },
26 SecurityLabelDisable {
28 enabled: bool,
30 },
31 SecurityLabelFileType {
33 file_type: String,
35 },
36 SecurityLabelLevel {
38 level: String,
40 },
41 SecurityLabelNested {
43 enabled: bool,
45 },
46 SecurityLabelType {
48 label_type: String,
50 },
51 Mask {
53 paths: String,
55 },
56 Unmask {
58 paths: String,
60 },
61 Expression,
63 Empty,
65 AppArmorNearMiss,
67 SeccompNearMiss,
69 NoNewPrivilegesNearMiss,
71 SecurityLabelDisableNearMiss,
73 SecurityLabelFileTypeNearMiss,
75 SecurityLabelLevelNearMiss,
77 SecurityLabelNestedNearMiss,
79 SecurityLabelTypeNearMiss,
81 MaskNearMiss,
83 UnmaskNearMiss,
85 Other,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SecurityOptionItem {
92 raw: Located<String>,
93 kind: SecurityOptionKind,
94}
95
96impl SecurityOptionItem {
97 pub(crate) fn parse(raw: Located<String>) -> Self {
98 let kind = classify_security_option(raw.value());
99 Self { raw, kind }
100 }
101
102 #[must_use]
104 pub fn value(&self) -> &str {
105 self.raw.value()
106 }
107
108 #[must_use]
110 pub const fn span(&self) -> SourceSpan {
111 self.raw.span()
112 }
113
114 #[must_use]
116 pub const fn kind(&self) -> &SecurityOptionKind {
117 &self.kind
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct SecurityOptions {
127 span: SourceSpan,
128 items: Vec<SecurityOptionItem>,
129}
130
131#[derive(Debug, Default)]
132pub(crate) struct SecurityOptionCandidateCounts {
133 pub(crate) apparmor: usize,
134 pub(crate) seccomp: usize,
135 pub(crate) no_new_privileges: usize,
136 pub(crate) security_label_disable: usize,
137 pub(crate) security_label_filetype: usize,
138 pub(crate) security_label_level: usize,
139 pub(crate) security_label_nested: usize,
140 pub(crate) security_label_type: usize,
141}
142
143impl SecurityOptions {
144 pub(crate) const fn new(span: SourceSpan, items: Vec<SecurityOptionItem>) -> Self {
145 Self { span, items }
146 }
147
148 #[must_use]
150 pub const fn span(&self) -> SourceSpan {
151 self.span
152 }
153
154 #[must_use]
156 pub fn items(&self) -> &[SecurityOptionItem] {
157 &self.items
158 }
159}
160
161pub(crate) fn classify_security_option(value: &str) -> SecurityOptionKind {
162 if value.contains('$') {
163 return SecurityOptionKind::Expression;
164 }
165 if value.is_empty() {
166 return SecurityOptionKind::Empty;
167 }
168 if let Some(profile) = value.strip_prefix("apparmor=") {
169 if !profile.is_empty() && !profile.chars().any(char::is_whitespace) {
170 return SecurityOptionKind::AppArmor {
171 profile: profile.to_owned(),
172 };
173 }
174 return SecurityOptionKind::AppArmorNearMiss;
175 }
176 if value
177 .trim()
178 .get(..8)
179 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("apparmor"))
180 {
181 return SecurityOptionKind::AppArmorNearMiss;
182 }
183 if let Some(profile) = value.strip_prefix("seccomp=") {
184 if !profile.is_empty() && !profile.chars().any(char::is_whitespace) {
185 return SecurityOptionKind::Seccomp {
186 profile: profile.to_owned(),
187 };
188 }
189 return SecurityOptionKind::SeccompNearMiss;
190 }
191 let compact = value
192 .chars()
193 .filter(|character| !character.is_whitespace())
194 .collect::<String>();
195 if let (Some(prefix), Some(suffix)) = (compact.get(..7), compact.get(7..)) {
196 if prefix.eq_ignore_ascii_case("seccomp")
197 && (suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with('='))
198 {
199 return SecurityOptionKind::SeccompNearMiss;
200 }
201 }
202 if let Some(kind) = classify_exact_boolean_option(value) {
203 return kind;
204 }
205 if let (Some(prefix), Some(suffix)) = (compact.get(..17), compact.get(17..)) {
206 if prefix.eq_ignore_ascii_case("no-new-privileges")
207 && (suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with('='))
208 {
209 return SecurityOptionKind::NoNewPrivilegesNearMiss;
210 }
211 }
212 if let Some(paths) = value.strip_prefix("mask=") {
213 return if !paths.is_empty() && !paths.chars().any(char::is_whitespace) {
214 SecurityOptionKind::Mask {
215 paths: paths.to_owned(),
216 }
217 } else {
218 SecurityOptionKind::MaskNearMiss
219 };
220 }
221 if is_mask_near_miss(value, &compact) {
222 return SecurityOptionKind::MaskNearMiss;
223 }
224 if let Some(paths) = value.strip_prefix("unmask=") {
225 return if valid_unmask_payload(paths) && !value.chars().any(char::is_whitespace) {
226 SecurityOptionKind::Unmask {
227 paths: paths.to_owned(),
228 }
229 } else {
230 SecurityOptionKind::UnmaskNearMiss
231 };
232 }
233 if is_unmask_near_miss(value, &compact) {
234 return SecurityOptionKind::UnmaskNearMiss;
235 }
236 classify_security_label_option(value, &compact).unwrap_or(SecurityOptionKind::Other)
237}
238
239fn valid_unmask_payload(paths: &str) -> bool {
240 paths == "ALL"
241 || paths
242 .split(':')
243 .all(|segment| !segment.is_empty() && segment.starts_with('/'))
244}
245
246fn is_mask_near_miss(value: &str, compact: &str) -> bool {
247 let Some(prefix) = compact.get(..4) else {
248 return false;
249 };
250 if !prefix.eq_ignore_ascii_case("mask") {
251 return false;
252 }
253 let suffix = &compact[4..];
254 suffix.is_empty()
255 || suffix.starts_with(['=', ':'])
256 || suffix
257 .chars()
258 .next()
259 .is_some_and(|delimiter| !delimiter.is_ascii_alphanumeric() && delimiter != '_')
260 || value
261 .trim_start()
262 .get(4..)
263 .and_then(|suffix| suffix.chars().next())
264 .is_some_and(char::is_whitespace)
265}
266
267fn is_unmask_near_miss(value: &str, compact: &str) -> bool {
268 let Some(prefix) = compact.get(..6) else {
269 return false;
270 };
271 if !prefix.eq_ignore_ascii_case("unmask") {
272 return false;
273 }
274 let suffix = &compact[6..];
275 suffix.is_empty()
276 || suffix.starts_with(['=', ':'])
277 || suffix
278 .chars()
279 .next()
280 .is_some_and(|delimiter| !delimiter.is_ascii_alphanumeric() && delimiter != '_')
281 || value
282 .trim_start()
283 .get(6..)
284 .and_then(|suffix| suffix.chars().next())
285 .is_some_and(char::is_whitespace)
286}
287
288fn classify_security_label_option(value: &str, compact: &str) -> Option<SecurityOptionKind> {
289 if let Some(file_type) = value.strip_prefix("label:filetype:") {
290 return Some(if !file_type.is_empty() && !value.chars().any(char::is_whitespace) {
291 SecurityOptionKind::SecurityLabelFileType {
292 file_type: file_type.to_owned(),
293 }
294 } else {
295 SecurityOptionKind::SecurityLabelFileTypeNearMiss
296 });
297 }
298 if let Some(level) = value.strip_prefix("label:level:") {
299 return Some(if !level.is_empty() && !value.chars().any(char::is_whitespace) {
300 SecurityOptionKind::SecurityLabelLevel {
301 level: level.to_owned(),
302 }
303 } else {
304 SecurityOptionKind::SecurityLabelLevelNearMiss
305 });
306 }
307 if let Some(label_type) = value.strip_prefix("label:type:") {
308 return Some(
309 if !label_type.is_empty() && !value.chars().any(char::is_whitespace) && !label_type.contains([':', '=']) {
310 SecurityOptionKind::SecurityLabelType {
311 label_type: label_type.to_owned(),
312 }
313 } else {
314 SecurityOptionKind::SecurityLabelTypeNearMiss
315 },
316 );
317 }
318 if compact.eq_ignore_ascii_case("label")
319 || compact.eq_ignore_ascii_case("label=disable")
320 || compact.eq_ignore_ascii_case("label:disable")
321 || compact
322 .get(..14)
323 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("label:disable:"))
324 {
325 return Some(SecurityOptionKind::SecurityLabelDisableNearMiss);
326 }
327 let lowercase_compact = compact.to_ascii_lowercase();
328 if lowercase_compact == "label:filetype"
329 || lowercase_compact.starts_with("label:filetype:")
330 || lowercase_compact.starts_with("label:filetype=")
331 || lowercase_compact.starts_with("label=filetype:")
332 || lowercase_compact.starts_with("label=filetype=")
333 {
334 return Some(SecurityOptionKind::SecurityLabelFileTypeNearMiss);
335 }
336 if lowercase_compact == "label:level"
337 || lowercase_compact == "label=level"
338 || lowercase_compact.starts_with("label:level:")
339 || lowercase_compact.starts_with("label:level=")
340 || lowercase_compact.starts_with("label=level:")
341 || lowercase_compact.starts_with("label=level=")
342 {
343 return Some(SecurityOptionKind::SecurityLabelLevelNearMiss);
344 }
345 if is_security_label_nested_near_miss(value, &lowercase_compact) {
346 return Some(SecurityOptionKind::SecurityLabelNestedNearMiss);
347 }
348 if is_security_label_type_near_miss(&lowercase_compact) {
349 return Some(SecurityOptionKind::SecurityLabelTypeNearMiss);
350 }
351 None
352}
353
354fn classify_exact_boolean_option(value: &str) -> Option<SecurityOptionKind> {
355 match value {
356 "no-new-privileges:true" => Some(SecurityOptionKind::NoNewPrivileges { enabled: true }),
357 "no-new-privileges:false" => Some(SecurityOptionKind::NoNewPrivileges { enabled: false }),
358 "label:disable" => Some(SecurityOptionKind::SecurityLabelDisable { enabled: true }),
359 "label:nested" => Some(SecurityOptionKind::SecurityLabelNested { enabled: true }),
360 _ => None,
361 }
362}
363
364fn is_security_label_nested_near_miss(value: &str, lowercase_compact: &str) -> bool {
365 lowercase_compact == "nested"
366 || lowercase_compact == "label=nested"
367 || lowercase_compact.starts_with("label:nested:")
368 || lowercase_compact.starts_with("label:nested=")
369 || lowercase_compact.starts_with("label=nested:")
370 || lowercase_compact.starts_with("label=nested=")
371 || (lowercase_compact == "label:nested" && value != "label:nested")
372}
373
374fn is_security_label_type_near_miss(lowercase_compact: &str) -> bool {
375 lowercase_compact == "type"
376 || lowercase_compact == "label:type"
377 || lowercase_compact == "label=type"
378 || lowercase_compact.starts_with("label:type:")
379 || lowercase_compact.starts_with("label:type=")
380 || lowercase_compact.starts_with("label=type:")
381 || lowercase_compact.starts_with("label=type=")
382}
383
384#[cfg(test)]
385mod tests {
386 use super::{SecurityOptionKind, classify_security_option};
387
388 #[test]
389 fn classifies_only_exact_lowercase_whitespace_free_security_option_candidates() {
390 assert!(matches!(
391 classify_security_option("apparmor=profile-a"),
392 SecurityOptionKind::AppArmor { profile } if profile == "profile-a"
393 ));
394 assert_eq!(
395 classify_security_option("${SECURITY_OPT}"),
396 SecurityOptionKind::Expression
397 );
398 assert_eq!(classify_security_option(""), SecurityOptionKind::Empty);
399 for (value, expected) in [
400 ("seccomp=unconfined", "unconfined"),
401 ("seccomp=/workspace/seccomp.json", "/workspace/seccomp.json"),
402 ] {
403 assert!(matches!(
404 classify_security_option(value),
405 SecurityOptionKind::Seccomp { profile } if profile == expected
406 ));
407 }
408 assert_eq!(
409 classify_security_option("seccomp=${SECCOMP_PROFILE}"),
410 SecurityOptionKind::Expression
411 );
412 for value in [
413 "seccomp",
414 "seccomp:",
415 "seccomp:unconfined",
416 "seccomp=",
417 "Seccomp=unconfined",
418 " seccomp=unconfined",
419 "seccomp =unconfined",
420 "seccomp=profile name",
421 ] {
422 assert_eq!(classify_security_option(value), SecurityOptionKind::SeccompNearMiss);
423 }
424 assert_eq!(
425 classify_security_option("seccomp-extra=true"),
426 SecurityOptionKind::Other
427 );
428 for value in [
429 "apparmor=",
430 "apparmor=profile name",
431 "AppArmor=profile-a",
432 "apparmor:profile-a",
433 " apparmor=profile-a",
434 ] {
435 assert_eq!(classify_security_option(value), SecurityOptionKind::AppArmorNearMiss);
436 }
437 assert_eq!(
438 classify_security_option("no-new-privileges:true"),
439 SecurityOptionKind::NoNewPrivileges { enabled: true }
440 );
441 assert_eq!(
442 classify_security_option("no-new-privileges:false"),
443 SecurityOptionKind::NoNewPrivileges { enabled: false }
444 );
445 for value in [
446 "no-new-privileges",
447 "no-new-privileges=true",
448 "No-New-Privileges:true",
449 " no-new-privileges:true",
450 "no-new-privileges: true",
451 ] {
452 assert_eq!(
453 classify_security_option(value),
454 SecurityOptionKind::NoNewPrivilegesNearMiss
455 );
456 }
457 assert_eq!(
458 classify_security_option("no-new-privileges-extra:true"),
459 SecurityOptionKind::Other
460 );
461 assert_eq!(
462 classify_security_option("label:disable"),
463 SecurityOptionKind::SecurityLabelDisable { enabled: true }
464 );
465 for value in [
466 "label=disable",
467 "label:disable:false",
468 "Label:disable",
469 " label:disable",
470 "label : disable",
471 "label",
472 ] {
473 assert_eq!(
474 classify_security_option(value),
475 SecurityOptionKind::SecurityLabelDisableNearMiss
476 );
477 }
478 for value in ["label:user:USER", "label:role:ROLE"] {
479 assert_eq!(classify_security_option(value), SecurityOptionKind::Other);
480 }
481 assert!(matches!(
482 classify_security_option("label:type:TYPE"),
483 SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
484 ));
485 assert_eq!(
486 classify_security_option("label:${LABEL_MODE}"),
487 SecurityOptionKind::Expression
488 );
489 }
490
491 #[test]
492 fn classifies_repeatable_mask_candidates_without_interpreting_payload_paths() {
493 for (value, expected) in [
494 ("mask=/run/secrets", "/run/secrets"),
495 ("mask=/proc/acpi:/proc/kcore", "/proc/acpi:/proc/kcore"),
496 ("mask=relative:opaque=value", "relative:opaque=value"),
497 ] {
498 assert!(matches!(
499 classify_security_option(value),
500 SecurityOptionKind::Mask { paths } if paths == expected
501 ));
502 }
503 for value in [
504 "mask",
505 "mask=",
506 "mask:/run/secrets",
507 "Mask=/run/secrets",
508 "MASK=/run/secrets",
509 " mask=/run/secrets",
510 "mask =/run/secrets",
511 "mask=/run/secret path",
512 "mask-/run/secrets",
513 ] {
514 assert_eq!(classify_security_option(value), SecurityOptionKind::MaskNearMiss);
515 }
516 assert_eq!(classify_security_option("masking=true"), SecurityOptionKind::Other);
517 assert_eq!(classify_security_option("masking true"), SecurityOptionKind::Other);
518 assert_eq!(
519 classify_security_option("mask=${MASK_PATHS}"),
520 SecurityOptionKind::Expression
521 );
522 }
523
524 #[test]
525 fn classifies_only_exact_repeatable_unmask_candidates() {
526 for (value, expected) in [
527 ("unmask=ALL", "ALL"),
528 ("unmask=/proc/acpi", "/proc/acpi"),
529 ("unmask=/proc/acpi:/sys/firmware", "/proc/acpi:/sys/firmware"),
530 ("unmask=/proc/*", "/proc/*"),
531 ] {
532 assert!(matches!(
533 classify_security_option(value),
534 SecurityOptionKind::Unmask { paths } if paths == expected
535 ));
536 }
537 for value in [
538 "unmask",
539 "unmask=",
540 "unmask=all",
541 "Unmask=ALL",
542 "UNMASK=/proc/acpi",
543 "unmask:/proc/acpi",
544 " unmask=/proc/acpi",
545 "unmask=/proc/acpi ",
546 "unmask =/proc/acpi",
547 "unmask=proc/acpi",
548 "unmask=/proc/acpi:relative",
549 "unmask=/proc/acpi:",
550 "unmask=:/proc/acpi",
551 "unmask=/proc/acpi::/sys/firmware",
552 "unmask=ALL:/proc/acpi",
553 "unmask=/proc/acpi:ALL",
554 "unmask-/proc/acpi",
555 ] {
556 assert_eq!(classify_security_option(value), SecurityOptionKind::UnmaskNearMiss);
557 }
558 assert_eq!(classify_security_option("unmasking=true"), SecurityOptionKind::Other);
559 assert_eq!(
560 classify_security_option("unmask=${UNMASK_PATHS}"),
561 SecurityOptionKind::Expression
562 );
563 }
564
565 #[test]
566 fn classifies_only_exact_label_filetype_candidates_and_precise_near_misses() {
567 assert!(matches!(
568 classify_security_option("label:filetype:container_file_t"),
569 SecurityOptionKind::SecurityLabelFileType { file_type }
570 if file_type == "container_file_t"
571 ));
572 for value in [
573 "label=filetype:container_file_t",
574 "label:filetype=container_file_t",
575 "Label:filetype:container_file_t",
576 "label:FileType:container_file_t",
577 " label:filetype:container_file_t",
578 "label:filetype:container file t",
579 "label:filetype:",
580 "label:filetype",
581 ] {
582 assert_eq!(
583 classify_security_option(value),
584 SecurityOptionKind::SecurityLabelFileTypeNearMiss
585 );
586 }
587 for value in ["label:user:USER", "label:role:ROLE"] {
588 assert_eq!(classify_security_option(value), SecurityOptionKind::Other);
589 }
590 assert!(matches!(
591 classify_security_option("label:type:TYPE"),
592 SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
593 ));
594 assert!(matches!(
595 classify_security_option("label:level:LEVEL"),
596 SecurityOptionKind::SecurityLabelLevel { level } if level == "LEVEL"
597 ));
598 assert_eq!(
599 classify_security_option("label:filetype:${LABEL_TYPE}"),
600 SecurityOptionKind::Expression
601 );
602 }
603
604 #[test]
605 fn classifies_only_exact_label_level_candidates_and_precise_near_misses() {
606 assert!(matches!(
607 classify_security_option("label:level:s0:c1,c2"),
608 SecurityOptionKind::SecurityLabelLevel { level }
609 if level == "s0:c1,c2"
610 ));
611 for value in [
612 "label=level:s0:c1,c2",
613 "label:level=s0:c1,c2",
614 "Label:level:s0:c1,c2",
615 "label:Level:s0:c1,c2",
616 " label:level:s0:c1,c2",
617 "label:level:s0 c1",
618 "label:level:",
619 "label:level",
620 "label=level",
621 ] {
622 assert_eq!(
623 classify_security_option(value),
624 SecurityOptionKind::SecurityLabelLevelNearMiss
625 );
626 }
627 for value in [
628 "label:type:TYPE",
629 "label:user:USER",
630 "label:role:ROLE",
631 "label:filetype:container_file_t",
632 "label:disable",
633 ] {
634 assert!(!matches!(
635 classify_security_option(value),
636 SecurityOptionKind::SecurityLabelLevel { .. } | SecurityOptionKind::SecurityLabelLevelNearMiss
637 ));
638 }
639 assert_eq!(
640 classify_security_option("label:level:${LABEL_LEVEL}"),
641 SecurityOptionKind::Expression
642 );
643 }
644
645 #[test]
646 fn classifies_only_exact_label_nested_candidates_and_precise_near_misses() {
647 assert_eq!(
648 classify_security_option("label:nested"),
649 SecurityOptionKind::SecurityLabelNested { enabled: true }
650 );
651 for value in [
652 "label=nested",
653 "Label:nested",
654 "label:Nested",
655 " label:nested",
656 "label : nested",
657 "label:nested:true",
658 "label:nested=",
659 "nested",
660 ] {
661 assert_eq!(
662 classify_security_option(value),
663 SecurityOptionKind::SecurityLabelNestedNearMiss
664 );
665 }
666 for value in [
667 "label:disable",
668 "label:filetype:container_file_t",
669 "label:level:s0:c1,c2",
670 "label:type:TYPE",
671 "label:user:USER",
672 "label:role:ROLE",
673 ] {
674 assert!(!matches!(
675 classify_security_option(value),
676 SecurityOptionKind::SecurityLabelNested { .. } | SecurityOptionKind::SecurityLabelNestedNearMiss
677 ));
678 }
679 assert_eq!(
680 classify_security_option("${LABEL_NESTED_OPTION}"),
681 SecurityOptionKind::Expression
682 );
683 }
684
685 #[test]
686 fn classifies_only_exact_label_type_candidates_and_precise_near_misses() {
687 assert!(matches!(
688 classify_security_option("label:type:container_t"),
689 SecurityOptionKind::SecurityLabelType { label_type }
690 if label_type == "container_t"
691 ));
692 assert!(matches!(
693 classify_security_option("label:type:TYPE"),
694 SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
695 ));
696 for value in [
697 "label=type:container_t",
698 "label:type=container_t",
699 "label=type=container_t",
700 "Label:type:container_t",
701 "label:Type:container_t",
702 " label:type:container_t",
703 "label : type : container_t",
704 "label:type:container t",
705 "label:type:",
706 "label:type",
707 "label=type",
708 "type",
709 "label:type:container_t:extended",
710 "label:type:container_t=extended",
711 ] {
712 assert_eq!(
713 classify_security_option(value),
714 SecurityOptionKind::SecurityLabelTypeNearMiss
715 );
716 }
717 for value in [
718 "label:disable",
719 "label:filetype:container_file_t",
720 "label:level:s0:c1,c2",
721 "label:nested",
722 "label:user:USER",
723 "label:role:ROLE",
724 ] {
725 assert!(!matches!(
726 classify_security_option(value),
727 SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss
728 ));
729 }
730 assert_eq!(
731 classify_security_option("label:type:${LABEL_TYPE}"),
732 SecurityOptionKind::Expression
733 );
734 }
735}