Skip to content

Commit 8e7ab2a

Browse files
7schmiedeclaude
andauthored
scanners+diff: surface labels/annotations and promote opt-in provenance (#20) (#21)
Layer 1 (additive): docker, kubernetes manifest, and helm chart scanners now emit `labels` / `annotations` from the parsed file in `Resource.extra` verbatim. Compose accepts both list-form (`["k=v"]`) and map-form labels; non-string scalars (numbers, bools) are coerced to strings so unquoted YAML values still surface instead of being silently dropped. Layer 2 (opt-in): a new `scan { provenance_labels = [...], provenance_annotations = [...] }` block in the Compositfile lifts configured keys to a structured `provenance` block on the resource. The first matching label provides `source_kind`, the first matching annotation provides `source_ref`, and every match goes into `raw`. The diff reporter appends `(source: <kind> <ref>)` to per-resource violation messages so drift attributable to an upstream spec generator points at the spec rather than the generated artefact. Resolution runs as a post-scan pass in `registry::run_all` next to the existing RFC-006 / RFC-007 passes, so individual scanners stay oblivious to the feature. Shared `yaml_string_map_to_json` helper extracted to `core::yaml_utils` to avoid duplicating coercion rules between scanners. Closes #20 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1047e75 commit 8e7ab2a

13 files changed

Lines changed: 942 additions & 20 deletions

File tree

src/commands/diff.rs

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -670,8 +670,10 @@ fn check_resources(governance: &Governance, report: &Report) -> ViolationCategor
670670
severity: Severity::Error,
671671
rule: "image_not_allowed".to_string(),
672672
message: format!(
673-
"Image \"{}\" not in allowed list for {}",
674-
image, rule.resource_type
673+
"Image \"{}\" not in allowed list for {}{}",
674+
image,
675+
rule.resource_type,
676+
provenance_suffix(r)
675677
),
676678
details: r.path.clone(),
677679
expected: Some(rule.allowed_images.join("\n")),
@@ -693,8 +695,10 @@ fn check_resources(governance: &Governance, report: &Report) -> ViolationCategor
693695
severity: Severity::Error,
694696
rule: "resource_subtype_not_allowed".to_string(),
695697
message: format!(
696-
"Resource type \"{}\" not in allowed types for {}",
697-
rt, rule.resource_type
698+
"Resource type \"{}\" not in allowed types for {}{}",
699+
rt,
700+
rule.resource_type,
701+
provenance_suffix(r)
698702
),
699703
details: r.path.clone(),
700704
expected: Some(rule.allowed_types.join("\n")),
@@ -854,8 +858,9 @@ fn check_resolution(report: &Report, scan: &ScanSettings) -> ViolationCategory {
854858
severity: Severity::Info,
855859
rule: "vault_unsupported".to_string(),
856860
message: format!(
857-
"Template {} is ansible-vault encrypted and was not rendered",
858-
r.path.as_deref().unwrap_or("-")
861+
"Template {} is ansible-vault encrypted and was not rendered{}",
862+
r.path.as_deref().unwrap_or("-"),
863+
provenance_suffix(r)
859864
),
860865
details: Some(
861866
"composit does not decrypt vault files. Role constraints on \
@@ -1145,6 +1150,25 @@ fn string_list(items: &[String]) -> String {
11451150
items.join("\n")
11461151
}
11471152

1153+
/// Issue #20: when a resource carries a `provenance` block (set by the
1154+
/// post-scan `apply_provenance` pass), append `(source: <kind> <ref>)` to
1155+
/// the violation message so reports point operators to the upstream spec
1156+
/// rather than the generated artefact. Empty string when no provenance
1157+
/// is present, so callers can append unconditionally.
1158+
fn provenance_suffix(r: &Resource) -> String {
1159+
let Some(prov) = r.extra.get("provenance").and_then(|v| v.as_object()) else {
1160+
return String::new();
1161+
};
1162+
let kind = prov.get("source_kind").and_then(|v| v.as_str());
1163+
let source_ref = prov.get("source_ref").and_then(|v| v.as_str());
1164+
match (kind, source_ref) {
1165+
(Some(k), Some(rf)) => format!(" (source: {} {})", k, rf),
1166+
(Some(k), None) => format!(" (source: {})", k),
1167+
(None, Some(rf)) => format!(" (source: {})", rf),
1168+
(None, None) => String::new(),
1169+
}
1170+
}
1171+
11481172
/// Human-readable summary of which resources a role matched. Used in the
11491173
/// `details` field of role violations so the HTML diff shows authors
11501174
/// *which* services tripped the rule, not just how many.
@@ -1248,8 +1272,10 @@ fn check_role_constraints(
12481272
severity: Severity::Error,
12491273
rule: "role_image_not_pinned".to_string(),
12501274
message: format!(
1251-
"Role \"{}\": image \"{}\" not in pinned list",
1252-
role.name, image
1275+
"Role \"{}\": image \"{}\" not in pinned list{}",
1276+
role.name,
1277+
image,
1278+
provenance_suffix(r)
12531279
),
12541280
details: Some(detail.clone()),
12551281
expected: Some(string_list(&role.image_pin)),
@@ -1270,8 +1296,10 @@ fn check_role_constraints(
12701296
severity: Severity::Error,
12711297
rule: "role_image_prefix_mismatch".to_string(),
12721298
message: format!(
1273-
"Role \"{}\": image \"{}\" does not match any allowed prefix",
1274-
role.name, image
1299+
"Role \"{}\": image \"{}\" does not match any allowed prefix{}",
1300+
role.name,
1301+
image,
1302+
provenance_suffix(r)
12751303
),
12761304
details: Some(detail.clone()),
12771305
expected: Some(string_list(&role.image_prefix)),
@@ -1297,8 +1325,11 @@ fn check_role_constraints(
12971325
severity: Severity::Error,
12981326
rule: "role_port_missing".to_string(),
12991327
message: format!(
1300-
"Role \"{}\": required ports {:?} not exposed (missing {:?})",
1301-
role.name, role.must_expose, missing
1328+
"Role \"{}\": required ports {:?} not exposed (missing {:?}){}",
1329+
role.name,
1330+
role.must_expose,
1331+
missing,
1332+
provenance_suffix(r)
13021333
),
13031334
details: Some(detail.clone()),
13041335
expected: Some(
@@ -1336,13 +1367,14 @@ fn check_role_constraints(
13361367
severity: Severity::Error,
13371368
rule: "role_network_missing".to_string(),
13381369
message: format!(
1339-
"Role \"{}\": not attached to required networks ({})",
1370+
"Role \"{}\": not attached to required networks ({}){}",
13401371
role.name,
13411372
missing
13421373
.iter()
13431374
.map(|s| s.as_str())
13441375
.collect::<Vec<_>>()
1345-
.join(", ")
1376+
.join(", "),
1377+
provenance_suffix(r)
13461378
),
13471379
details: Some(detail.clone()),
13481380
expected: Some(string_list(&role.must_attach_to)),
@@ -1371,13 +1403,14 @@ fn check_role_constraints(
13711403
severity: Severity::Error,
13721404
rule: "role_env_var_missing".to_string(),
13731405
message: format!(
1374-
"Role \"{}\": env vars not set: {}",
1406+
"Role \"{}\": env vars not set: {}{}",
13751407
role.name,
13761408
missing
13771409
.iter()
13781410
.map(|s| s.as_str())
13791411
.collect::<Vec<_>>()
1380-
.join(", ")
1412+
.join(", "),
1413+
provenance_suffix(r)
13811414
),
13821415
details: Some(detail.clone()),
13831416
expected: Some(string_list(&role.must_set_env)),
@@ -1402,13 +1435,14 @@ fn check_role_constraints(
14021435
severity: Severity::Error,
14031436
rule: "role_env_var_forbidden".to_string(),
14041437
message: format!(
1405-
"Role \"{}\": forbidden env vars present: {}",
1438+
"Role \"{}\": forbidden env vars present: {}{}",
14061439
role.name,
14071440
present
14081441
.iter()
14091442
.map(|s| s.as_str())
14101443
.collect::<Vec<_>>()
1411-
.join(", ")
1444+
.join(", "),
1445+
provenance_suffix(r)
14121446
),
14131447
details: Some(detail.clone()),
14141448
expected: Some(string_list(&role.forbidden_env)),
@@ -1478,8 +1512,9 @@ fn check_role_constraints(
14781512
severity: Severity::Error,
14791513
rule: "template_value_mismatch".to_string(),
14801514
message: format!(
1481-
"Role \"{}\": template rendering ({}) key \"{}\" does not satisfy \"{}\"",
1482-
role.name, src_tag, key, expected_glob
1515+
"Role \"{}\": template rendering ({}) key \"{}\" does not satisfy \"{}\"{}",
1516+
role.name, src_tag, key, expected_glob,
1517+
provenance_suffix(r)
14831518
),
14841519
details: Some(format!("{} @ {}", role_tag, path)),
14851520
expected: Some(format!("{} = {}", key, expected_glob)),

src/core/compositfile.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ fn parse_scan_block(block: &hcl::Block) -> Result<ScanSettings> {
6363
let exclude_paths = get_string_array_attr(&block.body, "exclude");
6464
let resolvable = get_optional_string_array_attr(&block.body, "resolvable");
6565
let redact = get_string_array_attr(&block.body, "redact");
66+
// Issue #20: opt-in provenance promotion. Each list names label /
67+
// annotation keys whose values get lifted to a structured `provenance`
68+
// block on every resource that carries them.
69+
let provenance_labels = get_string_array_attr(&block.body, "provenance_labels");
70+
let provenance_annotations = get_string_array_attr(&block.body, "provenance_annotations");
6671

6772
let mut extra_patterns = Vec::new();
6873
for inner in block.body.blocks() {
@@ -139,6 +144,8 @@ fn parse_scan_block(block: &hcl::Block) -> Result<ScanSettings> {
139144
resolvable,
140145
redact,
141146
ansible,
147+
provenance_labels,
148+
provenance_annotations,
142149
})
143150
}
144151

@@ -942,6 +949,53 @@ mod tests {
942949
assert!(gov.scan.is_scanner_enabled("docker"));
943950
}
944951

952+
#[test]
953+
fn test_scan_block_reads_provenance_attributes() {
954+
// Issue #20: provenance_labels and provenance_annotations are
955+
// string arrays inside `scan { }`. Both default to empty when
956+
// absent, which keeps the feature off for users who don't opt in.
957+
let gov = parse_hcl(
958+
r#"
959+
workspace "test" {
960+
scan {
961+
provenance_labels = ["app.kubernetes.io/managed-by", "vendor/source"]
962+
provenance_annotations = ["vendor/workload-name"]
963+
}
964+
}
965+
"#,
966+
)
967+
.unwrap();
968+
969+
assert_eq!(
970+
gov.scan.provenance_labels,
971+
vec![
972+
"app.kubernetes.io/managed-by".to_string(),
973+
"vendor/source".to_string(),
974+
]
975+
);
976+
assert_eq!(
977+
gov.scan.provenance_annotations,
978+
vec!["vendor/workload-name".to_string()]
979+
);
980+
}
981+
982+
#[test]
983+
fn test_scan_block_omitting_provenance_yields_empty_lists() {
984+
// Without the new attributes the lists must default to empty —
985+
// that's how `apply_provenance` short-circuits as a no-op for
986+
// users who haven't opted in.
987+
let gov = parse_hcl(
988+
r#"
989+
workspace "test" {
990+
scan {}
991+
}
992+
"#,
993+
)
994+
.unwrap();
995+
assert!(gov.scan.provenance_labels.is_empty());
996+
assert!(gov.scan.provenance_annotations.is_empty());
997+
}
998+
945999
#[test]
9461000
fn test_parse_role_block_with_matcher_and_constraints() {
9471001
let gov = parse_hcl(

src/core/governance.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ pub struct ScanSettings {
6161
/// into rendering.
6262
#[serde(default)]
6363
pub ansible: AnsibleSettings,
64+
65+
/// Issue #20: label keys whose values get promoted to a structured
66+
/// `provenance` block on the resource. The first key from this list
67+
/// that matches an actual label provides `provenance.source_kind`;
68+
/// every match is also recorded under `provenance.raw`. Empty default
69+
/// means provenance promotion is off (labels still surface verbatim
70+
/// via Layer 1).
71+
#[serde(default)]
72+
pub provenance_labels: Vec<String>,
73+
74+
/// Issue #20: annotation counterpart to `provenance_labels`. The first
75+
/// matching annotation provides `provenance.source_ref`. Empty default
76+
/// means provenance promotion is off for annotations.
77+
#[serde(default)]
78+
pub provenance_annotations: Vec<String>,
6479
}
6580

6681
/// RFC 007: Ansible rendering knobs.

src/core/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ pub mod attribution;
22
pub mod compositfile;
33
pub mod governance;
44
pub mod opa_eval;
5+
pub mod provenance;
56
pub mod registry;
67
pub mod rego;
78
pub mod report;
89
pub mod scanner;
910
pub mod types;
11+
pub mod yaml_utils;

0 commit comments

Comments
 (0)