gitlab-compliance 2.4.0__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/PKG-INFO +1 -1
  2. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/pyproject.toml +1 -1
  3. gitlab_compliance-2.5.0/src/__init__.py +1 -0
  4. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/behave_support/steps/script_steps.py +113 -18
  5. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/console.py +19 -5
  6. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/render.py +37 -11
  7. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/runner.py +188 -0
  8. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/script_analysis.py +384 -49
  9. gitlab_compliance-2.5.0/src/compliance/shell_lex.py +777 -0
  10. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/shell_render.py +34 -15
  11. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/gitlab_compliance.py +34 -0
  12. gitlab_compliance-2.4.0/src/__init__.py +0 -1
  13. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/LICENSE +0 -0
  14. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/docs/index.md +0 -0
  15. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/__init__.py +0 -0
  16. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/api_config.py +0 -0
  17. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/api_enrichment.py +0 -0
  18. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/behave_support/environment.py +0 -0
  19. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/behave_support/steps/__init__.py +0 -0
  20. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/behave_support/steps/given_steps.py +0 -0
  21. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/behave_support/steps/then_steps.py +0 -0
  22. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/behave_support/steps/when_steps.py +0 -0
  23. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/__init__.py +0 -0
  24. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/baseline-jobs.feature +0 -0
  25. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/component-inputs.feature +0 -0
  26. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/include-pinning.feature +0 -0
  27. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-ci-conventions.feature +0 -0
  28. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-command-substitution.feature +0 -0
  29. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-conditionals.feature +0 -0
  30. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-error-handling.feature +0 -0
  31. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-file-operations.feature +0 -0
  32. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-pinning.feature +0 -0
  33. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-pipelines.feature +0 -0
  34. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-portability.feature +0 -0
  35. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-quoting.feature +0 -0
  36. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-references.feature +0 -0
  37. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/shell/shell-security.feature +0 -0
  38. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/supply-chain/image-pinning.feature +0 -0
  39. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/supply-chain/include-versions.feature +0 -0
  40. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/supply-chain/service-pinning.feature +0 -0
  41. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/builtin_policies/variable-allowlist.feature +0 -0
  42. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/container_fix.py +0 -0
  43. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/gitlab_api.py +0 -0
  44. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/image_versions.py +0 -0
  45. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/include_fetch.py +0 -0
  46. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/include_fix.py +0 -0
  47. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/include_resolution.py +0 -0
  48. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/include_versions.py +0 -0
  49. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/merge_requests.py +0 -0
  50. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/metadata.py +0 -0
  51. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/model.py +0 -0
  52. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/models.py +0 -0
  53. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/oci_registry.py +0 -0
  54. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/policy_doc.py +0 -0
  55. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/policy_fix.py +0 -0
  56. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/release_cache.py +0 -0
  57. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/secret_redact.py +0 -0
  58. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/stash.py +0 -0
  59. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/compliance/supply_chain_fix.py +0 -0
  60. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/gitlab_docs.py +0 -0
  61. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/__init__.py +0 -0
  62. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/command_reference.py +0 -0
  63. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/common.py +0 -0
  64. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/constants.py +0 -0
  65. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/doc_controller.py +0 -0
  66. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/gitlab_reference.py +0 -0
  67. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/gitstrings.py +0 -0
  68. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/job_composition.py +0 -0
  69. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/logging.py +0 -0
  70. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/output_filters.py +0 -0
  71. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/pipeline_data.py +0 -0
  72. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/release.py +0 -0
  73. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/swagger_html.py +0 -0
  74. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/swagger_markdown.py +0 -0
  75. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/yaml_lines.py +0 -0
  76. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/modules/yaml_md_table.py +0 -0
  77. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/properties/__init__.py +0 -0
  78. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/properties/extract_job_attribute.py +0 -0
  79. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/properties/includes.py +0 -0
  80. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/properties/inputs.py +0 -0
  81. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/properties/jobs.py +0 -0
  82. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/properties/table_render.py +0 -0
  83. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/properties/variables.py +0 -0
  84. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/properties/workflows.py +0 -0
  85. {gitlab_compliance-2.4.0 → gitlab_compliance-2.5.0}/src/properties/yaml_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlab-compliance
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: BDD compliance testing for GitLab CI/CD pipelines and project settings — by MaturityBuilder
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gitlab-compliance"
3
- version = "2.4.0"
3
+ version = "2.5.0"
4
4
  dynamic = [ "readme" ]
5
5
  license = "Apache-2.0"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1 @@
1
+ __version__ = "2.4.0" # x-release-please-version
@@ -6,6 +6,33 @@ from behave import given, then, when
6
6
 
7
7
  from src.compliance.behave_support.environment import _skip_remaining_steps
8
8
  from src.compliance.script_analysis import (
9
+ evidence_backticks,
10
+ evidence_bashism_in_posix_test,
11
+ evidence_bashisms_without_bash,
12
+ evidence_chmod_777,
13
+ evidence_curl_missing_fail,
14
+ evidence_dangerous_rm,
15
+ evidence_deprecated_ci_build,
16
+ evidence_download_without_checksum,
17
+ evidence_eval,
18
+ evidence_hardcoded_secrets,
19
+ evidence_insecure_temp_files,
20
+ evidence_invalid_shebang,
21
+ evidence_masked_failure,
22
+ evidence_missing_strict_mode,
23
+ evidence_nested_backticks,
24
+ evidence_pipeline_without_pipefail,
25
+ evidence_posix_bashisms,
26
+ evidence_remote_pipe,
27
+ evidence_unpinned_docker,
28
+ evidence_unpinned_manager,
29
+ evidence_unquoted_command_substitution,
30
+ evidence_unquoted_path_variables,
31
+ evidence_unquoted_test_variables,
32
+ evidence_unquoted_variables,
33
+ evidence_unresolved_references,
34
+ evidence_unsafe_array_expansion,
35
+ evidence_unverified_git_clone,
9
36
  format_script_violation,
10
37
  job_has_effective_script,
11
38
  job_has_script_field,
@@ -49,6 +76,19 @@ from src.compliance.script_analysis import (
49
76
  from src.compliance.stash import filter_entities, name_starts_with
50
77
 
51
78
 
79
+ def _shell_check_verbose(context) -> bool:
80
+ config = getattr(context, "config", None)
81
+ userdata = getattr(config, "userdata", None) if config is not None else None
82
+ if not isinstance(userdata, dict):
83
+ return False
84
+ return str(userdata.get("shell_check_verbose", "false")).lower() in {
85
+ "true",
86
+ "1",
87
+ "yes",
88
+ "on",
89
+ }
90
+
91
+
52
92
  def _set_stash(context, entities: list[dict]):
53
93
  context.stash = list(entities)
54
94
  context.step_mode = "given"
@@ -72,32 +112,36 @@ def _apply_filter(context, entities: list[dict], reason: str):
72
112
  context.stash = entities
73
113
 
74
114
 
75
- def _assert_none(context, predicate, message: str):
115
+ def _assert_none(context, predicate, message: str, evidence=None):
76
116
  if context.scenario_skipped:
77
117
  return
78
118
  context.step_mode = "then"
79
119
  if not context.stash:
80
120
  raise AssertionError("No entities in stash to assert against.")
81
- failures = [
82
- format_script_violation(entity, message)
83
- for entity in context.stash
84
- if predicate(entity)
85
- ]
121
+ verbose = _shell_check_verbose(context)
122
+ failures = []
123
+ for entity in context.stash:
124
+ if not predicate(entity):
125
+ continue
126
+ found = evidence(entity) if verbose and evidence is not None else None
127
+ failures.append(format_script_violation(entity, message, found=found))
86
128
  if failures:
87
129
  raise AssertionError("; ".join(failures))
88
130
 
89
131
 
90
- def _assert_all_true(context, predicate, message: str):
132
+ def _assert_all_true(context, predicate, message: str, evidence=None):
91
133
  if context.scenario_skipped:
92
134
  return
93
135
  context.step_mode = "then"
94
136
  if not context.stash:
95
137
  raise AssertionError("No entities in stash to assert against.")
96
- failures = [
97
- format_script_violation(entity, message)
98
- for entity in context.stash
99
- if not predicate(entity)
100
- ]
138
+ verbose = _shell_check_verbose(context)
139
+ failures = []
140
+ for entity in context.stash:
141
+ if predicate(entity):
142
+ continue
143
+ found = evidence(entity) if verbose and evidence is not None else None
144
+ failures.append(format_script_violation(entity, message, found=found))
101
145
  if failures:
102
146
  raise AssertionError("; ".join(failures))
103
147
 
@@ -230,6 +274,7 @@ def then_variables_quoted(context):
230
274
  context,
231
275
  script_has_unquoted_variables,
232
276
  "Unquoted variable expansions found",
277
+ evidence=evidence_unquoted_variables,
233
278
  )
234
279
 
235
280
 
@@ -239,6 +284,7 @@ def then_path_variables_quoted(context):
239
284
  context,
240
285
  script_has_unquoted_path_variables,
241
286
  "Unquoted variables used in file paths",
287
+ evidence=evidence_unquoted_path_variables,
242
288
  )
243
289
 
244
290
 
@@ -248,6 +294,7 @@ def then_cmd_sub_quoted(context):
248
294
  context,
249
295
  script_has_unquoted_command_substitution,
250
296
  "Unquoted command substitution found",
297
+ evidence=evidence_unquoted_command_substitution,
251
298
  )
252
299
 
253
300
 
@@ -257,6 +304,7 @@ def then_array_at_syntax(context):
257
304
  context,
258
305
  script_has_unsafe_array_expansion,
259
306
  "Unsafe array expansion found",
307
+ evidence=evidence_unsafe_array_expansion,
260
308
  )
261
309
 
262
310
 
@@ -266,6 +314,7 @@ def then_strict_mode_table(context):
266
314
  context,
267
315
  script_enables_strict_mode,
268
316
  "Multi-line script missing set -e/-u/pipefail",
317
+ evidence=evidence_missing_strict_mode,
269
318
  )
270
319
 
271
320
 
@@ -277,7 +326,12 @@ def then_failure_handled(context):
277
326
  return True
278
327
  return not script_masks_failures(entity)
279
328
 
280
- _assert_all_true(context, ok, "Command failures are not handled explicitly")
329
+ _assert_all_true(
330
+ context,
331
+ ok,
332
+ "Command failures are not handled explicitly",
333
+ evidence=evidence_masked_failure,
334
+ )
281
335
 
282
336
 
283
337
  @then("functions must propagate failures")
@@ -291,6 +345,7 @@ def then_functions_propagate(context):
291
345
  context,
292
346
  script_functions_mask_failures,
293
347
  "Functions mask failures with || true",
348
+ evidence=evidence_masked_failure,
294
349
  )
295
350
 
296
351
 
@@ -300,6 +355,7 @@ def then_file_paths_quoted(context):
300
355
  context,
301
356
  script_has_unquoted_path_variables,
302
357
  "Unquoted file paths found",
358
+ evidence=evidence_unquoted_path_variables,
303
359
  )
304
360
 
305
361
 
@@ -309,6 +365,7 @@ def then_mktemp_used(context):
309
365
  context,
310
366
  script_uses_insecure_temp_files,
311
367
  "Insecure temporary file creation without mktemp",
368
+ evidence=evidence_insecure_temp_files,
312
369
  )
313
370
 
314
371
 
@@ -318,12 +375,18 @@ def then_rm_protected(context):
318
375
  context,
319
376
  script_has_dangerous_rm,
320
377
  "Dangerous recursive rm without path validation",
378
+ evidence=evidence_dangerous_rm,
321
379
  )
322
380
 
323
381
 
324
382
  @then("command substitution must use dollar parentheses")
325
383
  def then_no_backticks(context):
326
- _assert_none(context, script_uses_backticks, "Legacy backtick substitution found")
384
+ _assert_none(
385
+ context,
386
+ script_uses_backticks,
387
+ "Legacy backtick substitution found",
388
+ evidence=evidence_backticks,
389
+ )
327
390
 
328
391
 
329
392
  @then("nested command substitution must use dollar parentheses")
@@ -332,6 +395,7 @@ def then_nested_dollar(context):
332
395
  context,
333
396
  script_has_nested_backticks,
334
397
  "Nested backtick substitution found",
398
+ evidence=evidence_nested_backticks,
335
399
  )
336
400
 
337
401
 
@@ -341,6 +405,7 @@ def then_posix_operators(context):
341
405
  context,
342
406
  script_has_bashism_in_posix_test,
343
407
  "Non-portable operators in [ tests",
408
+ evidence=evidence_bashism_in_posix_test,
344
409
  )
345
410
 
346
411
 
@@ -350,6 +415,7 @@ def then_test_vars_quoted(context):
350
415
  context,
351
416
  script_has_unquoted_test_variables,
352
417
  "Unquoted variables in test expressions",
418
+ evidence=evidence_unquoted_test_variables,
353
419
  )
354
420
 
355
421
 
@@ -359,12 +425,15 @@ def then_pipefail(context):
359
425
  context,
360
426
  lambda e: (not script_has_pipeline(e)) or script_has_pipefail(e),
361
427
  "Pipeline used without pipefail",
428
+ evidence=evidence_pipeline_without_pipefail,
362
429
  )
363
430
 
364
431
 
365
432
  @then("eval must not be used")
366
433
  def then_no_eval(context):
367
- _assert_none(context, script_uses_eval, "eval is used in script")
434
+ _assert_none(
435
+ context, script_uses_eval, "eval is used in script", evidence=evidence_eval
436
+ )
368
437
 
369
438
 
370
439
  @then("untrusted remote scripts must not be executed")
@@ -373,6 +442,7 @@ def then_no_remote_pipe(context):
373
442
  context,
374
443
  script_has_remote_pipe_to_shell,
375
444
  "Remote pipe to shell (curl|sh) detected",
445
+ evidence=evidence_remote_pipe,
376
446
  )
377
447
 
378
448
 
@@ -382,6 +452,7 @@ def then_user_input_safe(context):
382
452
  context,
383
453
  script_has_unquoted_variables,
384
454
  "User/CI variables used without quoting",
455
+ evidence=evidence_unquoted_variables,
385
456
  )
386
457
 
387
458
 
@@ -391,6 +462,7 @@ def then_no_secrets(context):
391
462
  context,
392
463
  script_has_hardcoded_secrets,
393
464
  "Hardcoded secret pattern detected",
465
+ evidence=evidence_hardcoded_secrets,
394
466
  )
395
467
 
396
468
 
@@ -400,6 +472,7 @@ def then_downloads_checksum(context):
400
472
  context,
401
473
  script_downloads_without_checksum,
402
474
  "Download without nearby checksum verification",
475
+ evidence=evidence_download_without_checksum,
403
476
  )
404
477
 
405
478
 
@@ -416,7 +489,12 @@ def then_packages_pinned(context, manager):
416
489
  predicate = predicates.get(manager)
417
490
  if predicate is None:
418
491
  raise AssertionError(f"Unknown package manager '{manager}'")
419
- _assert_none(context, predicate, f"Unpinned {manager} package install")
492
+ _assert_none(
493
+ context,
494
+ predicate,
495
+ f"Unpinned {manager} package install",
496
+ evidence=lambda entity: evidence_unpinned_manager(entity, manager),
497
+ )
420
498
 
421
499
 
422
500
  @then("docker commands must pin container images to a tag or sha256 digest")
@@ -425,6 +503,7 @@ def then_docker_images_pinned(context):
425
503
  context,
426
504
  script_has_unpinned_docker_image,
427
505
  "Docker command references an unpinned container image",
506
+ evidence=evidence_unpinned_docker,
428
507
  )
429
508
 
430
509
 
@@ -434,12 +513,18 @@ def then_git_clone_verified(context):
434
513
  context,
435
514
  script_has_unverified_git_clone,
436
515
  "git clone without commit/tag verification",
516
+ evidence=evidence_unverified_git_clone,
437
517
  )
438
518
 
439
519
 
440
520
  @then("curl must use fail flag")
441
521
  def then_curl_fail(context):
442
- _assert_none(context, script_curl_missing_fail, "curl without --fail/-f")
522
+ _assert_none(
523
+ context,
524
+ script_curl_missing_fail,
525
+ "curl without --fail/-f",
526
+ evidence=evidence_curl_missing_fail,
527
+ )
443
528
 
444
529
 
445
530
  @then("deprecated CI_BUILD variables must not be used")
@@ -450,6 +535,7 @@ def then_no_ci_build(context):
450
535
  context,
451
536
  script_uses_deprecated_ci_build_vars,
452
537
  "Deprecated $CI_BUILD_* variable used",
538
+ evidence=evidence_deprecated_ci_build,
453
539
  )
454
540
 
455
541
 
@@ -459,6 +545,7 @@ def then_no_unresolved_refs(context):
459
545
  context,
460
546
  script_has_unresolved_references,
461
547
  "Unresolved !reference in script blocks",
548
+ evidence=evidence_unresolved_references,
462
549
  )
463
550
 
464
551
 
@@ -468,6 +555,7 @@ def then_shebang_valid(context):
468
555
  context,
469
556
  lambda e: (not script_has_shebang(e)) or script_shebang_is_valid(e),
470
557
  "Invalid shebang in script block",
558
+ evidence=evidence_invalid_shebang,
471
559
  )
472
560
 
473
561
 
@@ -477,6 +565,7 @@ def then_bash_declared(context):
477
565
  context,
478
566
  script_bashisms_without_bash_shebang,
479
567
  "Bash-specific syntax without bash shebang",
568
+ evidence=evidence_bashisms_without_bash,
480
569
  )
481
570
 
482
571
 
@@ -486,9 +575,15 @@ def then_posix_no_bashisms(context):
486
575
  context,
487
576
  script_posix_shebang_with_bashisms,
488
577
  "Bashisms used with sh shebang",
578
+ evidence=evidence_posix_bashisms,
489
579
  )
490
580
 
491
581
 
492
582
  @then("chmod 777 must not be used")
493
583
  def then_no_chmod_777(context):
494
- _assert_none(context, script_has_chmod_777, "chmod 777 is used")
584
+ _assert_none(
585
+ context,
586
+ script_has_chmod_777,
587
+ "chmod 777 is used",
588
+ evidence=evidence_chmod_777,
589
+ )
@@ -183,14 +183,16 @@ def _summary_table(result: ComplianceResult, *, narrow: bool) -> Table:
183
183
  return summary
184
184
 
185
185
 
186
- def _scenario_table(result: ComplianceResult, *, width: int) -> Table | None:
186
+ def _scenario_table(
187
+ result: ComplianceResult, *, width: int, failures_only: bool = False
188
+ ) -> Table | None:
187
189
  if not result.scenario_results:
188
190
  return None
189
191
 
190
192
  narrow = width < 60
191
193
  medium = width < 100
192
194
  scenarios = Table(
193
- title="Scenarios",
195
+ title="Scenarios" if not failures_only else "Failed scenarios",
194
196
  show_header=True,
195
197
  header_style="bold",
196
198
  expand=True,
@@ -204,7 +206,10 @@ def _scenario_table(result: ComplianceResult, *, width: int) -> Table | None:
204
206
  scenarios.add_column("Policy", overflow="fold")
205
207
  scenarios.add_column("Status", justify="center", no_wrap=True)
206
208
 
209
+ rows_added = 0
207
210
  for scenario in result.scenario_results:
211
+ if failures_only and scenario.status != "failed":
212
+ continue
208
213
  style = _STATUS_STYLES.get(scenario.status, "bold white")
209
214
  policy = scenario.title or scenario.name
210
215
  row: list[str | Text] = []
@@ -214,10 +219,15 @@ def _scenario_table(result: ComplianceResult, *, width: int) -> Table | None:
214
219
  row.append(scenario.feature)
215
220
  row.extend([policy, Text(scenario.status.upper(), style=style)])
216
221
  scenarios.add_row(*row)
222
+ rows_added += 1
223
+ if rows_added == 0:
224
+ return None
217
225
  return scenarios
218
226
 
219
227
 
220
- def _detail_panels(result: ComplianceResult) -> list[Panel]:
228
+ def _detail_panels(
229
+ result: ComplianceResult, *, failures_only: bool = False
230
+ ) -> list[Panel]:
221
231
  blocks: list[Panel] = []
222
232
  failures = [
223
233
  scenario for scenario in result.scenario_results if scenario.status == "failed"
@@ -248,6 +258,9 @@ def _detail_panels(result: ComplianceResult) -> list[Panel]:
248
258
  )
249
259
  )
250
260
 
261
+ if failures_only:
262
+ return blocks
263
+
251
264
  skipped = [
252
265
  scenario for scenario in result.scenario_results if scenario.status == "skipped"
253
266
  ]
@@ -285,6 +298,7 @@ def render_compliance_console(
285
298
  *,
286
299
  command_title: str = "check",
287
300
  console: Console | None = None,
301
+ failures_only: bool = False,
288
302
  ) -> None:
289
303
  """Render a width-aware Rich compliance report."""
290
304
  out = console or get_console()
@@ -313,10 +327,10 @@ def render_compliance_console(
313
327
  )
314
328
  out.print(_summary_table(result, narrow=narrow))
315
329
 
316
- for block in _detail_panels(result):
330
+ for block in _detail_panels(result, failures_only=failures_only):
317
331
  out.print(block)
318
332
 
319
- scenarios = _scenario_table(result, width=width)
333
+ scenarios = _scenario_table(result, width=width, failures_only=failures_only)
320
334
  if scenarios is not None:
321
335
  out.print(scenarios)
322
336
 
@@ -8,6 +8,7 @@ import json
8
8
  import os
9
9
  import re
10
10
  import xml.etree.ElementTree as ET # nosec B405 - XML is generated, not parsed
11
+ from dataclasses import replace
11
12
  from datetime import datetime, timezone
12
13
 
13
14
  from src.compliance.models import ComplianceResult, ScenarioResult
@@ -473,34 +474,43 @@ def render_compliance_junit(
473
474
  pipeline_file: str,
474
475
  *,
475
476
  suite_name: str = "gitlab-compliance",
477
+ failures_only: bool = False,
476
478
  ) -> str:
477
479
  """Render compliance scenarios as JUnit XML for CI test report artifacts."""
480
+ scenarios = list(result.scenario_results)
481
+ if failures_only:
482
+ scenarios = [s for s in scenarios if s.status == "failed"]
483
+
484
+ failed = sum(1 for s in scenarios if s.status == "failed")
485
+ skipped = sum(1 for s in scenarios if s.status == "skipped")
478
486
  root = ET.Element(
479
487
  "testsuites",
480
488
  {
481
489
  "name": suite_name,
482
- "tests": str(result.scenarios),
483
- "failures": str(result.failed),
490
+ "tests": str(len(scenarios)),
491
+ "failures": str(failed),
484
492
  "errors": "0",
485
- "skipped": str(result.skipped),
493
+ "skipped": str(skipped),
486
494
  },
487
495
  )
488
496
 
489
- for feature, scenarios in _group_scenarios_by_feature(
490
- result.scenario_results
491
- ).items():
497
+ for feature, feature_scenarios in _group_scenarios_by_feature(scenarios).items():
492
498
  suite = ET.SubElement(
493
499
  root,
494
500
  "testsuite",
495
501
  {
496
502
  "name": feature,
497
- "tests": str(len(scenarios)),
498
- "failures": str(sum(1 for s in scenarios if s.status == "failed")),
503
+ "tests": str(len(feature_scenarios)),
504
+ "failures": str(
505
+ sum(1 for s in feature_scenarios if s.status == "failed")
506
+ ),
499
507
  "errors": "0",
500
- "skipped": str(sum(1 for s in scenarios if s.status == "skipped")),
508
+ "skipped": str(
509
+ sum(1 for s in feature_scenarios if s.status == "skipped")
510
+ ),
501
511
  },
502
512
  )
503
- for scenario in scenarios:
513
+ for scenario in feature_scenarios:
504
514
  testcase = ET.SubElement(
505
515
  suite,
506
516
  "testcase",
@@ -557,6 +567,7 @@ def render_compliance_report(
557
567
  output_format: str,
558
568
  *,
559
569
  suite_name: str = "gitlab-compliance",
570
+ failures_only: bool = False,
560
571
  ) -> str | None:
561
572
  fmt = output_format.lower()
562
573
  if fmt == "markdown":
@@ -566,7 +577,22 @@ def render_compliance_report(
566
577
  if fmt == "mr-comment":
567
578
  return render_compliance_mr_comment(result, pipeline_file, features_dir)
568
579
  if fmt == "codequality":
580
+ if failures_only:
581
+ filtered = [s for s in result.scenario_results if s.status == "failed"]
582
+ result = replace(
583
+ result,
584
+ scenario_results=filtered,
585
+ scenarios=len(filtered),
586
+ passed=0,
587
+ failed=len(filtered),
588
+ skipped=0,
589
+ )
569
590
  return render_compliance_code_quality(result, pipeline_file)
570
591
  if fmt == "junit":
571
- return render_compliance_junit(result, pipeline_file, suite_name=suite_name)
592
+ return render_compliance_junit(
593
+ result,
594
+ pipeline_file,
595
+ suite_name=suite_name,
596
+ failures_only=failures_only,
597
+ )
572
598
  return None