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 && prefix.eq_ignore_ascii_case("seccomp")
197 && (suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with('='))
198 {
199 return SecurityOptionKind::SeccompNearMiss;
200 }
201 if let Some(kind) = classify_exact_boolean_option(value) {
202 return kind;
203 }
204 if let (Some(prefix), Some(suffix)) = (compact.get(..17), compact.get(17..))
205 && prefix.eq_ignore_ascii_case("no-new-privileges")
206 && (suffix.is_empty() || suffix.starts_with(':') || suffix.starts_with('='))
207 {
208 return SecurityOptionKind::NoNewPrivilegesNearMiss;
209 }
210 if let Some(paths) = value.strip_prefix("mask=") {
211 return if !paths.is_empty() && !paths.chars().any(char::is_whitespace) {
212 SecurityOptionKind::Mask {
213 paths: paths.to_owned(),
214 }
215 } else {
216 SecurityOptionKind::MaskNearMiss
217 };
218 }
219 if is_mask_near_miss(value, &compact) {
220 return SecurityOptionKind::MaskNearMiss;
221 }
222 if let Some(paths) = value.strip_prefix("unmask=") {
223 return if valid_unmask_payload(paths) && !value.chars().any(char::is_whitespace) {
224 SecurityOptionKind::Unmask {
225 paths: paths.to_owned(),
226 }
227 } else {
228 SecurityOptionKind::UnmaskNearMiss
229 };
230 }
231 if is_unmask_near_miss(value, &compact) {
232 return SecurityOptionKind::UnmaskNearMiss;
233 }
234 classify_security_label_option(value, &compact).unwrap_or(SecurityOptionKind::Other)
235}
236
237fn valid_unmask_payload(paths: &str) -> bool {
238 paths == "ALL"
239 || paths
240 .split(':')
241 .all(|segment| !segment.is_empty() && segment.starts_with('/'))
242}
243
244fn is_mask_near_miss(value: &str, compact: &str) -> bool {
245 let Some(prefix) = compact.get(..4) else {
246 return false;
247 };
248 if !prefix.eq_ignore_ascii_case("mask") {
249 return false;
250 }
251 let suffix = &compact[4..];
252 suffix.is_empty()
253 || suffix.starts_with(['=', ':'])
254 || suffix
255 .chars()
256 .next()
257 .is_some_and(|delimiter| !delimiter.is_ascii_alphanumeric() && delimiter != '_')
258 || value
259 .trim_start()
260 .get(4..)
261 .and_then(|suffix| suffix.chars().next())
262 .is_some_and(char::is_whitespace)
263}
264
265fn is_unmask_near_miss(value: &str, compact: &str) -> bool {
266 let Some(prefix) = compact.get(..6) else {
267 return false;
268 };
269 if !prefix.eq_ignore_ascii_case("unmask") {
270 return false;
271 }
272 let suffix = &compact[6..];
273 suffix.is_empty()
274 || suffix.starts_with(['=', ':'])
275 || suffix
276 .chars()
277 .next()
278 .is_some_and(|delimiter| !delimiter.is_ascii_alphanumeric() && delimiter != '_')
279 || value
280 .trim_start()
281 .get(6..)
282 .and_then(|suffix| suffix.chars().next())
283 .is_some_and(char::is_whitespace)
284}
285
286fn classify_security_label_option(value: &str, compact: &str) -> Option<SecurityOptionKind> {
287 if let Some(file_type) = value.strip_prefix("label:filetype:") {
288 return Some(if !file_type.is_empty() && !value.chars().any(char::is_whitespace) {
289 SecurityOptionKind::SecurityLabelFileType {
290 file_type: file_type.to_owned(),
291 }
292 } else {
293 SecurityOptionKind::SecurityLabelFileTypeNearMiss
294 });
295 }
296 if let Some(level) = value.strip_prefix("label:level:") {
297 return Some(if !level.is_empty() && !value.chars().any(char::is_whitespace) {
298 SecurityOptionKind::SecurityLabelLevel {
299 level: level.to_owned(),
300 }
301 } else {
302 SecurityOptionKind::SecurityLabelLevelNearMiss
303 });
304 }
305 if let Some(label_type) = value.strip_prefix("label:type:") {
306 return Some(
307 if !label_type.is_empty() && !value.chars().any(char::is_whitespace) && !label_type.contains([':', '=']) {
308 SecurityOptionKind::SecurityLabelType {
309 label_type: label_type.to_owned(),
310 }
311 } else {
312 SecurityOptionKind::SecurityLabelTypeNearMiss
313 },
314 );
315 }
316 if compact.eq_ignore_ascii_case("label")
317 || compact.eq_ignore_ascii_case("label=disable")
318 || compact.eq_ignore_ascii_case("label:disable")
319 || compact
320 .get(..14)
321 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("label:disable:"))
322 {
323 return Some(SecurityOptionKind::SecurityLabelDisableNearMiss);
324 }
325 let lowercase_compact = compact.to_ascii_lowercase();
326 if lowercase_compact == "label:filetype"
327 || lowercase_compact.starts_with("label:filetype:")
328 || lowercase_compact.starts_with("label:filetype=")
329 || lowercase_compact.starts_with("label=filetype:")
330 || lowercase_compact.starts_with("label=filetype=")
331 {
332 return Some(SecurityOptionKind::SecurityLabelFileTypeNearMiss);
333 }
334 if lowercase_compact == "label:level"
335 || lowercase_compact == "label=level"
336 || lowercase_compact.starts_with("label:level:")
337 || lowercase_compact.starts_with("label:level=")
338 || lowercase_compact.starts_with("label=level:")
339 || lowercase_compact.starts_with("label=level=")
340 {
341 return Some(SecurityOptionKind::SecurityLabelLevelNearMiss);
342 }
343 if is_security_label_nested_near_miss(value, &lowercase_compact) {
344 return Some(SecurityOptionKind::SecurityLabelNestedNearMiss);
345 }
346 if is_security_label_type_near_miss(&lowercase_compact) {
347 return Some(SecurityOptionKind::SecurityLabelTypeNearMiss);
348 }
349 None
350}
351
352fn classify_exact_boolean_option(value: &str) -> Option<SecurityOptionKind> {
353 match value {
354 "no-new-privileges:true" => Some(SecurityOptionKind::NoNewPrivileges { enabled: true }),
355 "no-new-privileges:false" => Some(SecurityOptionKind::NoNewPrivileges { enabled: false }),
356 "label:disable" => Some(SecurityOptionKind::SecurityLabelDisable { enabled: true }),
357 "label:nested" => Some(SecurityOptionKind::SecurityLabelNested { enabled: true }),
358 _ => None,
359 }
360}
361
362fn is_security_label_nested_near_miss(value: &str, lowercase_compact: &str) -> bool {
363 lowercase_compact == "nested"
364 || lowercase_compact == "label=nested"
365 || lowercase_compact.starts_with("label:nested:")
366 || lowercase_compact.starts_with("label:nested=")
367 || lowercase_compact.starts_with("label=nested:")
368 || lowercase_compact.starts_with("label=nested=")
369 || (lowercase_compact == "label:nested" && value != "label:nested")
370}
371
372fn is_security_label_type_near_miss(lowercase_compact: &str) -> bool {
373 lowercase_compact == "type"
374 || lowercase_compact == "label:type"
375 || lowercase_compact == "label=type"
376 || lowercase_compact.starts_with("label:type:")
377 || lowercase_compact.starts_with("label:type=")
378 || lowercase_compact.starts_with("label=type:")
379 || lowercase_compact.starts_with("label=type=")
380}
381
382#[cfg(test)]
383mod tests {
384 use super::{SecurityOptionKind, classify_security_option};
385
386 #[test]
387 fn classifies_only_exact_lowercase_whitespace_free_security_option_candidates() {
388 assert!(matches!(
389 classify_security_option("apparmor=profile-a"),
390 SecurityOptionKind::AppArmor { profile } if profile == "profile-a"
391 ));
392 assert_eq!(
393 classify_security_option("${SECURITY_OPT}"),
394 SecurityOptionKind::Expression
395 );
396 assert_eq!(classify_security_option(""), SecurityOptionKind::Empty);
397 for (value, expected) in [
398 ("seccomp=unconfined", "unconfined"),
399 ("seccomp=/workspace/seccomp.json", "/workspace/seccomp.json"),
400 ] {
401 assert!(matches!(
402 classify_security_option(value),
403 SecurityOptionKind::Seccomp { profile } if profile == expected
404 ));
405 }
406 assert_eq!(
407 classify_security_option("seccomp=${SECCOMP_PROFILE}"),
408 SecurityOptionKind::Expression
409 );
410 for value in [
411 "seccomp",
412 "seccomp:",
413 "seccomp:unconfined",
414 "seccomp=",
415 "Seccomp=unconfined",
416 " seccomp=unconfined",
417 "seccomp =unconfined",
418 "seccomp=profile name",
419 ] {
420 assert_eq!(classify_security_option(value), SecurityOptionKind::SeccompNearMiss);
421 }
422 assert_eq!(
423 classify_security_option("seccomp-extra=true"),
424 SecurityOptionKind::Other
425 );
426 for value in [
427 "apparmor=",
428 "apparmor=profile name",
429 "AppArmor=profile-a",
430 "apparmor:profile-a",
431 " apparmor=profile-a",
432 ] {
433 assert_eq!(classify_security_option(value), SecurityOptionKind::AppArmorNearMiss);
434 }
435 assert_eq!(
436 classify_security_option("no-new-privileges:true"),
437 SecurityOptionKind::NoNewPrivileges { enabled: true }
438 );
439 assert_eq!(
440 classify_security_option("no-new-privileges:false"),
441 SecurityOptionKind::NoNewPrivileges { enabled: false }
442 );
443 for value in [
444 "no-new-privileges",
445 "no-new-privileges=true",
446 "No-New-Privileges:true",
447 " no-new-privileges:true",
448 "no-new-privileges: true",
449 ] {
450 assert_eq!(
451 classify_security_option(value),
452 SecurityOptionKind::NoNewPrivilegesNearMiss
453 );
454 }
455 assert_eq!(
456 classify_security_option("no-new-privileges-extra:true"),
457 SecurityOptionKind::Other
458 );
459 assert_eq!(
460 classify_security_option("label:disable"),
461 SecurityOptionKind::SecurityLabelDisable { enabled: true }
462 );
463 for value in [
464 "label=disable",
465 "label:disable:false",
466 "Label:disable",
467 " label:disable",
468 "label : disable",
469 "label",
470 ] {
471 assert_eq!(
472 classify_security_option(value),
473 SecurityOptionKind::SecurityLabelDisableNearMiss
474 );
475 }
476 for value in ["label:user:USER", "label:role:ROLE"] {
477 assert_eq!(classify_security_option(value), SecurityOptionKind::Other);
478 }
479 assert!(matches!(
480 classify_security_option("label:type:TYPE"),
481 SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
482 ));
483 assert_eq!(
484 classify_security_option("label:${LABEL_MODE}"),
485 SecurityOptionKind::Expression
486 );
487 }
488
489 #[test]
490 fn classifies_repeatable_mask_candidates_without_interpreting_payload_paths() {
491 for (value, expected) in [
492 ("mask=/run/secrets", "/run/secrets"),
493 ("mask=/proc/acpi:/proc/kcore", "/proc/acpi:/proc/kcore"),
494 ("mask=relative:opaque=value", "relative:opaque=value"),
495 ] {
496 assert!(matches!(
497 classify_security_option(value),
498 SecurityOptionKind::Mask { paths } if paths == expected
499 ));
500 }
501 for value in [
502 "mask",
503 "mask=",
504 "mask:/run/secrets",
505 "Mask=/run/secrets",
506 "MASK=/run/secrets",
507 " mask=/run/secrets",
508 "mask =/run/secrets",
509 "mask=/run/secret path",
510 "mask-/run/secrets",
511 ] {
512 assert_eq!(classify_security_option(value), SecurityOptionKind::MaskNearMiss);
513 }
514 assert_eq!(classify_security_option("masking=true"), SecurityOptionKind::Other);
515 assert_eq!(classify_security_option("masking true"), SecurityOptionKind::Other);
516 assert_eq!(
517 classify_security_option("mask=${MASK_PATHS}"),
518 SecurityOptionKind::Expression
519 );
520 }
521
522 #[test]
523 fn classifies_only_exact_repeatable_unmask_candidates() {
524 for (value, expected) in [
525 ("unmask=ALL", "ALL"),
526 ("unmask=/proc/acpi", "/proc/acpi"),
527 ("unmask=/proc/acpi:/sys/firmware", "/proc/acpi:/sys/firmware"),
528 ("unmask=/proc/*", "/proc/*"),
529 ] {
530 assert!(matches!(
531 classify_security_option(value),
532 SecurityOptionKind::Unmask { paths } if paths == expected
533 ));
534 }
535 for value in [
536 "unmask",
537 "unmask=",
538 "unmask=all",
539 "Unmask=ALL",
540 "UNMASK=/proc/acpi",
541 "unmask:/proc/acpi",
542 " unmask=/proc/acpi",
543 "unmask=/proc/acpi ",
544 "unmask =/proc/acpi",
545 "unmask=proc/acpi",
546 "unmask=/proc/acpi:relative",
547 "unmask=/proc/acpi:",
548 "unmask=:/proc/acpi",
549 "unmask=/proc/acpi::/sys/firmware",
550 "unmask=ALL:/proc/acpi",
551 "unmask=/proc/acpi:ALL",
552 "unmask-/proc/acpi",
553 ] {
554 assert_eq!(classify_security_option(value), SecurityOptionKind::UnmaskNearMiss);
555 }
556 assert_eq!(classify_security_option("unmasking=true"), SecurityOptionKind::Other);
557 assert_eq!(
558 classify_security_option("unmask=${UNMASK_PATHS}"),
559 SecurityOptionKind::Expression
560 );
561 }
562
563 #[test]
564 fn classifies_only_exact_label_filetype_candidates_and_precise_near_misses() {
565 assert!(matches!(
566 classify_security_option("label:filetype:container_file_t"),
567 SecurityOptionKind::SecurityLabelFileType { file_type }
568 if file_type == "container_file_t"
569 ));
570 for value in [
571 "label=filetype:container_file_t",
572 "label:filetype=container_file_t",
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:",
578 "label:filetype",
579 ] {
580 assert_eq!(
581 classify_security_option(value),
582 SecurityOptionKind::SecurityLabelFileTypeNearMiss
583 );
584 }
585 for value in ["label:user:USER", "label:role:ROLE"] {
586 assert_eq!(classify_security_option(value), SecurityOptionKind::Other);
587 }
588 assert!(matches!(
589 classify_security_option("label:type:TYPE"),
590 SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
591 ));
592 assert!(matches!(
593 classify_security_option("label:level:LEVEL"),
594 SecurityOptionKind::SecurityLabelLevel { level } if level == "LEVEL"
595 ));
596 assert_eq!(
597 classify_security_option("label:filetype:${LABEL_TYPE}"),
598 SecurityOptionKind::Expression
599 );
600 }
601
602 #[test]
603 fn classifies_only_exact_label_level_candidates_and_precise_near_misses() {
604 assert!(matches!(
605 classify_security_option("label:level:s0:c1,c2"),
606 SecurityOptionKind::SecurityLabelLevel { level }
607 if level == "s0:c1,c2"
608 ));
609 for value in [
610 "label=level:s0:c1,c2",
611 "label:level=s0:c1,c2",
612 "Label:level:s0:c1,c2",
613 "label:Level:s0:c1,c2",
614 " label:level:s0:c1,c2",
615 "label:level:s0 c1",
616 "label:level:",
617 "label:level",
618 "label=level",
619 ] {
620 assert_eq!(
621 classify_security_option(value),
622 SecurityOptionKind::SecurityLabelLevelNearMiss
623 );
624 }
625 for value in [
626 "label:type:TYPE",
627 "label:user:USER",
628 "label:role:ROLE",
629 "label:filetype:container_file_t",
630 "label:disable",
631 ] {
632 assert!(!matches!(
633 classify_security_option(value),
634 SecurityOptionKind::SecurityLabelLevel { .. } | SecurityOptionKind::SecurityLabelLevelNearMiss
635 ));
636 }
637 assert_eq!(
638 classify_security_option("label:level:${LABEL_LEVEL}"),
639 SecurityOptionKind::Expression
640 );
641 }
642
643 #[test]
644 fn classifies_only_exact_label_nested_candidates_and_precise_near_misses() {
645 assert_eq!(
646 classify_security_option("label:nested"),
647 SecurityOptionKind::SecurityLabelNested { enabled: true }
648 );
649 for value in [
650 "label=nested",
651 "Label:nested",
652 "label:Nested",
653 " label:nested",
654 "label : nested",
655 "label:nested:true",
656 "label:nested=",
657 "nested",
658 ] {
659 assert_eq!(
660 classify_security_option(value),
661 SecurityOptionKind::SecurityLabelNestedNearMiss
662 );
663 }
664 for value in [
665 "label:disable",
666 "label:filetype:container_file_t",
667 "label:level:s0:c1,c2",
668 "label:type:TYPE",
669 "label:user:USER",
670 "label:role:ROLE",
671 ] {
672 assert!(!matches!(
673 classify_security_option(value),
674 SecurityOptionKind::SecurityLabelNested { .. } | SecurityOptionKind::SecurityLabelNestedNearMiss
675 ));
676 }
677 assert_eq!(
678 classify_security_option("${LABEL_NESTED_OPTION}"),
679 SecurityOptionKind::Expression
680 );
681 }
682
683 #[test]
684 fn classifies_only_exact_label_type_candidates_and_precise_near_misses() {
685 assert!(matches!(
686 classify_security_option("label:type:container_t"),
687 SecurityOptionKind::SecurityLabelType { label_type }
688 if label_type == "container_t"
689 ));
690 assert!(matches!(
691 classify_security_option("label:type:TYPE"),
692 SecurityOptionKind::SecurityLabelType { label_type } if label_type == "TYPE"
693 ));
694 for value in [
695 "label=type:container_t",
696 "label:type=container_t",
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:",
704 "label:type",
705 "label=type",
706 "type",
707 "label:type:container_t:extended",
708 "label:type:container_t=extended",
709 ] {
710 assert_eq!(
711 classify_security_option(value),
712 SecurityOptionKind::SecurityLabelTypeNearMiss
713 );
714 }
715 for value in [
716 "label:disable",
717 "label:filetype:container_file_t",
718 "label:level:s0:c1,c2",
719 "label:nested",
720 "label:user:USER",
721 "label:role:ROLE",
722 ] {
723 assert!(!matches!(
724 classify_security_option(value),
725 SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss
726 ));
727 }
728 assert_eq!(
729 classify_security_option("label:type:${LABEL_TYPE}"),
730 SecurityOptionKind::Expression
731 );
732 }
733}