From 588102b972e8ccd591a2081ad12aad1c9a028370 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:05:28 +0900 Subject: [PATCH 01/34] test(security): reject malformed MISP to_ids evidence --- tests/misp_to_ids_admission.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 tests/misp_to_ids_admission.rs diff --git a/tests/misp_to_ids_admission.rs b/tests/misp_to_ids_admission.rs new file mode 100644 index 0000000..38cfc27 --- /dev/null +++ b/tests/misp_to_ids_admission.rs @@ -0,0 +1,17 @@ +#[path = "../src/misp_import.rs"] +mod misp_import; + +#[test] +fn malformed_to_ids_values_fail_closed() { + let raw = r#"[ + {"type":"domain","value":"object.example","to_ids":{"unexpected":true}}, + {"type":"domain","value":"null.example","to_ids":null}, + {"type":"domain","value":"valid.example","to_ids":true} + ]"#; + + let material = misp_import::parse_misp_document(raw, "misp:test", 60).unwrap(); + + assert_eq!(material.threats.len(), 1); + assert_eq!(material.threats[0].value, "valid.example"); + assert_eq!(material.skipped_attributes, 2); +} From f2b648fa11ef6eee8c0db71d2d8ea7bd32aa03f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:07:17 +0900 Subject: [PATCH 02/34] fix(security): fail closed on malformed MISP to_ids --- src/misp_import.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/misp_import.rs b/src/misp_import.rs index 65d0707..6f37d18 100644 --- a/src/misp_import.rs +++ b/src/misp_import.rs @@ -212,14 +212,15 @@ fn materialize_attribute( severity: Severity, event_label: &str, ) -> AttributeOutcome { - // Skip non-IDS attributes when to_ids is explicitly false (MISP convention). + // MISP defines `to_ids` as a boolean. Retain the existing scalar compatibility + // spellings, but never turn an explicitly malformed structured value into IDS evidence. let to_ids = attr .get("to_ids") .map(|v| match v { serde_json::Value::Bool(b) => *b, serde_json::Value::String(s) => s == "1" || s.eq_ignore_ascii_case("true"), serde_json::Value::Number(n) => n.as_u64() == Some(1), - _ => true, + _ => false, }) .unwrap_or(true); if !to_ids { From bfa6fd4f3c83fc5e2db18117a78330d460c1870c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:07:29 +0900 Subject: [PATCH 03/34] test(security): cover malformed MISP to_ids arrays --- tests/misp_to_ids_admission.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/misp_to_ids_admission.rs b/tests/misp_to_ids_admission.rs index 38cfc27..ee824b1 100644 --- a/tests/misp_to_ids_admission.rs +++ b/tests/misp_to_ids_admission.rs @@ -5,6 +5,7 @@ mod misp_import; fn malformed_to_ids_values_fail_closed() { let raw = r#"[ {"type":"domain","value":"object.example","to_ids":{"unexpected":true}}, + {"type":"domain","value":"array.example","to_ids":[true]}, {"type":"domain","value":"null.example","to_ids":null}, {"type":"domain","value":"valid.example","to_ids":true} ]"#; @@ -13,5 +14,5 @@ fn malformed_to_ids_values_fail_closed() { assert_eq!(material.threats.len(), 1); assert_eq!(material.threats[0].value, "valid.example"); - assert_eq!(material.skipped_attributes, 2); + assert_eq!(material.skipped_attributes, 3); } From 8f9c85491adf02c542508920c82dc231ae38033c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:14:41 +0900 Subject: [PATCH 04/34] test(security): require explicit MISP IDS admission --- tests/misp_to_ids_admission.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/misp_to_ids_admission.rs b/tests/misp_to_ids_admission.rs index ee824b1..abbdcdd 100644 --- a/tests/misp_to_ids_admission.rs +++ b/tests/misp_to_ids_admission.rs @@ -2,11 +2,12 @@ mod misp_import; #[test] -fn malformed_to_ids_values_fail_closed() { +fn malformed_or_missing_to_ids_values_fail_closed() { let raw = r#"[ {"type":"domain","value":"object.example","to_ids":{"unexpected":true}}, {"type":"domain","value":"array.example","to_ids":[true]}, {"type":"domain","value":"null.example","to_ids":null}, + {"type":"domain","value":"missing.example"}, {"type":"domain","value":"valid.example","to_ids":true} ]"#; @@ -14,5 +15,5 @@ fn malformed_to_ids_values_fail_closed() { assert_eq!(material.threats.len(), 1); assert_eq!(material.threats[0].value, "valid.example"); - assert_eq!(material.skipped_attributes, 3); + assert_eq!(material.skipped_attributes, 4); } From 966e8cc1c340d0c1d66c419a95ea1203a36aa76b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:16:15 +0900 Subject: [PATCH 05/34] fix(security): require affirmative MISP IDS evidence --- src/misp_import.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/misp_import.rs b/src/misp_import.rs index 6f37d18..34b187b 100644 --- a/src/misp_import.rs +++ b/src/misp_import.rs @@ -212,8 +212,9 @@ fn materialize_attribute( severity: Severity, event_label: &str, ) -> AttributeOutcome { - // MISP defines `to_ids` as a boolean. Retain the existing scalar compatibility - // spellings, but never turn an explicitly malformed structured value into IDS evidence. + // MISP's `to_ids` contract is affirmative evidence. Preserve the previously supported + // scalar true spellings, but absent or malformed values cannot authorize enforcement. + // See docs/doctoring/misp-to-ids-admission.md. let to_ids = attr .get("to_ids") .map(|v| match v { @@ -222,7 +223,7 @@ fn materialize_attribute( serde_json::Value::Number(n) => n.as_u64() == Some(1), _ => false, }) - .unwrap_or(true); + .unwrap_or(false); if !to_ids { return AttributeOutcome::Skipped; } From b1df88ca891af09b284af2342c71dab45bbe0723 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:16:39 +0900 Subject: [PATCH 06/34] docs(security): trace MISP IDS admission policy --- docs/doctoring/misp-to-ids-admission.md | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/doctoring/misp-to-ids-admission.md diff --git a/docs/doctoring/misp-to-ids-admission.md b/docs/doctoring/misp-to-ids-admission.md new file mode 100644 index 0000000..27d9d3f --- /dev/null +++ b/docs/doctoring/misp-to-ids-admission.md @@ -0,0 +1,33 @@ +# MISP `to_ids` admission policy + +## Decision boundary + +Wardnet converts selected MISP attributes into gateway enforcement material (`ThreatIndicator` and, for IPs, `DnsblEntry`). The conversion is therefore an admission decision, not a neutral JSON import. `to_ids` is treated as affirmative evidence that an attribute is intended for detection. An attribute is admitted only when Wardnet can positively recognize that signal as true. + +The MISP object schema defines `to_ids` as a boolean, and the MISP-STIX converter documents that `to_ids=True` is the detection-ready case that becomes an Indicator. MISP REST search can also return attributes regardless of their `to_ids` setting unless the caller filters them. Wardnet consequently cannot infer IDS authorization from mere presence in a MISP document. Missing, `false`, `null`, object, array, and otherwise unrecognized `to_ids` values fail closed and contribute to `skipped_attributes`; they do not produce enforcement rows. The previously supported explicit scalar compatibility spellings (`"1"`, case-insensitive `"true"`, numeric `1`) remain accepted so the repair does not broaden unrelated parser incompatibility. + +This is the security-design application of fail-safe defaults: permission to create enforcement material is established by positive evidence rather than by the absence of a denial. Saltzer and Schroeder (1975) describe that protection principle as defaulting to lack of permission unless conditions for access are established. For the parser boundary here, the analogous safe state is “do not create an enforcement indicator” when the upstream authorization signal is absent or structurally invalid. + +The hostile regression intentionally supplies valid JSON whose `to_ids` member is semantically malformed or absent. That test style is consistent with the fuzzing literature's treatment of syntactically and semantically malformed inputs as a primary way to expose security-relevant parser behavior (Manès et al., 2021). Wardnet already archives the permitted arXiv version of that survey at [`../papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf`](../papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf). The Saltzer–Schroeder paper is linked to the authors' MIT-hosted web rendering rather than copied into the repository because the hosted metadata retains the authors' 1975 copyright; this document does not manufacture a redistribution right. + +## Alternatives considered + +Keeping `unwrap_or(true)` was rejected because an omitted field would silently become stronger evidence than MISP supplied. Treating arbitrary JSON values as true was rejected because a producer/schema defect would widen enforcement. Rejecting the entire MISP document was also rejected for this bounded repair: `skipped_attributes` already provides a per-attribute fail-closed path and allows valid sibling indicators to remain usable while preserving input-quality evidence. A future contract version may remove the legacy scalar compatibility spellings, but that is a separate compatibility decision and is not required to close this defect. + +## Verification contract + +`tests/misp_to_ids_admission.rs` is the focused security regression. It requires object, array, `null`, and missing `to_ids` values to be skipped while an explicit boolean `true` control remains admitted. Existing unit coverage retains boolean `false` rejection and the supported explicit scalar compatibility cases. Merge evidence must come from the exact current PR head and then-live Wardnet CI/fuzz/security/review gates; source reasoning or predecessor runs are not a substitute. + +## Traceability and references + +MISP Project. (n.d.). *misp-objects: Definition, description and relationship types of MISP objects* (`schema_objects.json`). GitHub. https://github.com/MISP/misp-objects/blob/main/schema_objects.json + +MISP Project. (n.d.). *MISP-STIX converter*. GitHub. https://github.com/MISP/misp-stix + +Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in computer systems. *Proceedings of the IEEE, 63*(9), 1278–1308. https://doi.org/10.1109/PROC.1975.9939 + +Author-hosted rendering: https://web.mit.edu/saltzer/www/publications/protection/index.html + +Manès, V. J. M., Han, H., Han, C., Cha, S. K., Egele, M., Schwartz, E. J., & Woo, M. (2021). The art, science, and engineering of fuzzing: A survey. *IEEE Transactions on Software Engineering, 47*(11), 2312–2331. https://doi.org/10.1109/TSE.2019.2946563 + +Repository archive: [`../papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf`](../papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf) From 03da951e5e4b41cbde91f48c6a32a9c7f43db872 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 08:16:52 +0900 Subject: [PATCH 07/34] docs(test): link MISP admission evidence --- tests/misp_to_ids_admission.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/misp_to_ids_admission.rs b/tests/misp_to_ids_admission.rs index abbdcdd..77df69a 100644 --- a/tests/misp_to_ids_admission.rs +++ b/tests/misp_to_ids_admission.rs @@ -3,6 +3,7 @@ mod misp_import; #[test] fn malformed_or_missing_to_ids_values_fail_closed() { + // Policy and research traceability: docs/doctoring/misp-to-ids-admission.md. let raw = r#"[ {"type":"domain","value":"object.example","to_ids":{"unexpected":true}}, {"type":"domain","value":"array.example","to_ids":[true]}, From bdd5f5bae43421f6d306ca8cb1e77af122ccef3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:04:08 +0900 Subject: [PATCH 08/34] test(security): reject deleted MISP attributes --- tests/misp_to_ids_admission.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/misp_to_ids_admission.rs b/tests/misp_to_ids_admission.rs index 77df69a..10b949e 100644 --- a/tests/misp_to_ids_admission.rs +++ b/tests/misp_to_ids_admission.rs @@ -18,3 +18,33 @@ fn malformed_or_missing_to_ids_values_fail_closed() { assert_eq!(material.threats[0].value, "valid.example"); assert_eq!(material.skipped_attributes, 4); } + +#[test] +fn deleted_misp_attributes_cannot_authorize_enforcement() { + // A positive `to_ids` signal cannot resurrect an attribute MISP marks deleted. + let raw = r#"[ + {"type":"domain","value":"deleted-bool.example","to_ids":true,"deleted":true}, + {"type":"domain","value":"deleted-string.example","to_ids":true,"deleted":"1"}, + {"type":"domain","value":"malformed-deleted.example","to_ids":true,"deleted":{"unexpected":false}}, + {"type":"domain","value":"active-bool.example","to_ids":true,"deleted":false}, + {"type":"domain","value":"active-string.example","to_ids":true,"deleted":"0"}, + {"type":"domain","value":"active-omitted.example","to_ids":true} + ]"#; + + let material = misp_import::parse_misp_document(raw, "misp:test", 60).unwrap(); + + assert_eq!(material.threats.len(), 3); + assert!(material + .threats + .iter() + .any(|threat| threat.value == "active-bool.example")); + assert!(material + .threats + .iter() + .any(|threat| threat.value == "active-string.example")); + assert!(material + .threats + .iter() + .any(|threat| threat.value == "active-omitted.example")); + assert_eq!(material.skipped_attributes, 3); +} From 33a8d542b1f71754a521bd599e0351be5cb175ef Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:14:59 +0900 Subject: [PATCH 09/34] fix(security): reject deleted MISP attributes --- src/misp_import.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/misp_import.rs b/src/misp_import.rs index 34b187b..9226386 100644 --- a/src/misp_import.rs +++ b/src/misp_import.rs @@ -224,7 +224,21 @@ fn materialize_attribute( _ => false, }) .unwrap_or(false); - if !to_ids { + + // MISP publishes deletion state independently of `to_ids`: a formerly actionable + // attribute may retain `to_ids=true` while `deleted` marks it withdrawn. Omission is + // accepted for compatibility with active exports; any present unrecognized state fails + // closed instead of resurrecting withdrawn or structurally invalid enforcement data. + let active = attr + .get("deleted") + .map(|v| match v { + serde_json::Value::Bool(b) => !*b, + serde_json::Value::String(s) => s == "0" || s.eq_ignore_ascii_case("false"), + serde_json::Value::Number(n) => n.as_u64() == Some(0), + _ => false, + }) + .unwrap_or(true); + if !to_ids || !active { return AttributeOutcome::Skipped; } @@ -511,4 +525,4 @@ mod tests { ) .is_err()); } -} +} \ No newline at end of file From 5b99ee9faeaf5d6f10c17a0d8e891dc2f67f9d46 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 12:15:27 +0900 Subject: [PATCH 10/34] docs(security): trace deleted MISP admission --- docs/doctoring/misp-to-ids-admission.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/misp-to-ids-admission.md b/docs/doctoring/misp-to-ids-admission.md index 27d9d3f..1474c8d 100644 --- a/docs/doctoring/misp-to-ids-admission.md +++ b/docs/doctoring/misp-to-ids-admission.md @@ -2,21 +2,23 @@ ## Decision boundary -Wardnet converts selected MISP attributes into gateway enforcement material (`ThreatIndicator` and, for IPs, `DnsblEntry`). The conversion is therefore an admission decision, not a neutral JSON import. `to_ids` is treated as affirmative evidence that an attribute is intended for detection. An attribute is admitted only when Wardnet can positively recognize that signal as true. +Wardnet converts selected MISP attributes into gateway enforcement material (`ThreatIndicator` and, for IPs, `DnsblEntry`). The conversion is therefore an admission decision, not a neutral JSON import. `to_ids` is treated as affirmative evidence that an attribute is intended for detection, and an attribute is admitted only when Wardnet can positively recognize that signal as true and can establish that the attribute has not been withdrawn. The MISP object schema defines `to_ids` as a boolean, and the MISP-STIX converter documents that `to_ids=True` is the detection-ready case that becomes an Indicator. MISP REST search can also return attributes regardless of their `to_ids` setting unless the caller filters them. Wardnet consequently cannot infer IDS authorization from mere presence in a MISP document. Missing, `false`, `null`, object, array, and otherwise unrecognized `to_ids` values fail closed and contribute to `skipped_attributes`; they do not produce enforcement rows. The previously supported explicit scalar compatibility spellings (`"1"`, case-insensitive `"true"`, numeric `1`) remain accepted so the repair does not broaden unrelated parser incompatibility. -This is the security-design application of fail-safe defaults: permission to create enforcement material is established by positive evidence rather than by the absence of a denial. Saltzer and Schroeder (1975) describe that protection principle as defaulting to lack of permission unless conditions for access are established. For the parser boundary here, the analogous safe state is “do not create an enforcement indicator” when the upstream authorization signal is absent or structurally invalid. +MISP's ZeroMQ contract also exposes deletion independently of `to_ids`: examples use `deleted: false`/`"0"` for active material and set `deleted` to `1` when an attribute is deleted, with subscribers instructed to inspect the deletion marker. A positive `to_ids` value therefore cannot resurrect withdrawn material. Wardnet treats recognized `deleted=true`/`1` states as withdrawn; recognized `false`/`0` states and an omitted `deleted` member remain active for compatibility with ordinary exports. A present but structurally unrecognized deletion state fails closed rather than becoming an implicit active signal. -The hostile regression intentionally supplies valid JSON whose `to_ids` member is semantically malformed or absent. That test style is consistent with the fuzzing literature's treatment of syntactically and semantically malformed inputs as a primary way to expose security-relevant parser behavior (Manès et al., 2021). Wardnet already archives the permitted arXiv version of that survey at [`../papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf`](../papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf). The Saltzer–Schroeder paper is linked to the authors' MIT-hosted web rendering rather than copied into the repository because the hosted metadata retains the authors' 1975 copyright; this document does not manufacture a redistribution right. +This is the security-design application of fail-safe defaults: permission to create enforcement material is established by positive evidence rather than by the absence of a denial. Saltzer and Schroeder (1975) describe that protection principle as defaulting to lack of permission unless conditions for access are established. For the parser boundary here, the analogous safe state is “do not create an enforcement indicator” when the upstream authorization signal is absent, withdrawn, or structurally invalid. + +The hostile regressions intentionally supply valid JSON whose admission members are semantically malformed, absent, or contradictory with lifecycle state. That test style is consistent with the fuzzing literature's treatment of syntactically and semantically malformed inputs as a primary way to expose security-relevant parser behavior (Manès et al., 2021). Wardnet already archives the permitted arXiv version of that survey at [`../papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf`](../papers/fuzzing-art-science-engineering-survey-arxiv-1812.00140.pdf). The Saltzer–Schroeder paper is linked to the authors' MIT-hosted web rendering rather than copied into the repository because the hosted metadata retains the authors' 1975 copyright; this document does not manufacture a redistribution right. ## Alternatives considered -Keeping `unwrap_or(true)` was rejected because an omitted field would silently become stronger evidence than MISP supplied. Treating arbitrary JSON values as true was rejected because a producer/schema defect would widen enforcement. Rejecting the entire MISP document was also rejected for this bounded repair: `skipped_attributes` already provides a per-attribute fail-closed path and allows valid sibling indicators to remain usable while preserving input-quality evidence. A future contract version may remove the legacy scalar compatibility spellings, but that is a separate compatibility decision and is not required to close this defect. +Keeping `unwrap_or(true)` for `to_ids` was rejected because an omitted field would silently become stronger evidence than MISP supplied. Treating arbitrary JSON values as true was rejected because a producer/schema defect would widen enforcement. Treating an unknown or malformed `deleted` value as active was rejected for the same reason: lifecycle ambiguity must not revive enforcement material. Rejecting the entire MISP document was also rejected for this bounded repair: `skipped_attributes` already provides a per-attribute fail-closed path and allows valid sibling indicators to remain usable while preserving input-quality evidence. A future contract version may remove the legacy scalar compatibility spellings, but that is a separate compatibility decision and is not required to close this defect. ## Verification contract -`tests/misp_to_ids_admission.rs` is the focused security regression. It requires object, array, `null`, and missing `to_ids` values to be skipped while an explicit boolean `true` control remains admitted. Existing unit coverage retains boolean `false` rejection and the supported explicit scalar compatibility cases. Merge evidence must come from the exact current PR head and then-live Wardnet CI/fuzz/security/review gates; source reasoning or predecessor runs are not a substitute. +`tests/misp_to_ids_admission.rs` is the focused security regression. It requires object, array, `null`, and missing `to_ids` values to be skipped while an explicit boolean `true` control remains admitted. It also requires `deleted=true`, `deleted="1"`, and a structurally malformed deletion marker to be skipped even when `to_ids=true`, while `deleted=false`, `deleted="0"`, and omitted deletion state remain active. Existing unit coverage retains boolean `to_ids=false` rejection and the supported explicit scalar compatibility cases. Merge evidence must come from the exact current PR head and then-live Wardnet CI/fuzz/security/review gates; source reasoning or predecessor runs are not a substitute. ## Traceability and references @@ -24,6 +26,8 @@ MISP Project. (n.d.). *misp-objects: Definition, description and relationship ty MISP Project. (n.d.). *MISP-STIX converter*. GitHub. https://github.com/MISP/misp-stix +MISP Project. (n.d.). *MISP ZeroMQ documentation*. GitHub. https://github.com/MISP/misp-book/blob/main/misp-zmq/README.md + Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in computer systems. *Proceedings of the IEEE, 63*(9), 1278–1308. https://doi.org/10.1109/PROC.1975.9939 Author-hosted rendering: https://web.mit.edu/saltzer/www/publications/protection/index.html From 84c16a2d649833f855f31893df6787007d33c503 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:01:49 +0900 Subject: [PATCH 11/34] test(misp): reject deleted parent objects --- tests/misp_to_ids_admission.rs | 60 ++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/misp_to_ids_admission.rs b/tests/misp_to_ids_admission.rs index 10b949e..2ab7dc2 100644 --- a/tests/misp_to_ids_admission.rs +++ b/tests/misp_to_ids_admission.rs @@ -48,3 +48,63 @@ fn deleted_misp_attributes_cannot_authorize_enforcement() { .any(|threat| threat.value == "active-omitted.example")); assert_eq!(material.skipped_attributes, 3); } + +#[test] +fn deleted_or_ambiguous_misp_objects_cannot_authorize_nested_attributes() { + // MISP objects carry an independent lifecycle marker. A nested attribute's + // `to_ids=true` cannot override a deleted or structurally ambiguous parent object. + let raw = r#"{ + "Event": { + "id": "object-lifecycle", + "Object": [ + { + "name": "deleted-bool", + "deleted": true, + "Attribute": [ + {"type":"domain","value":"deleted-object-bool.example","to_ids":true,"deleted":false} + ] + }, + { + "name": "deleted-string", + "deleted": "1", + "Attribute": [ + {"type":"domain","value":"deleted-object-string.example","to_ids":true} + ] + }, + { + "name": "ambiguous-object", + "deleted": {"unexpected": false}, + "Attribute": [ + {"type":"domain","value":"ambiguous-object.example","to_ids":true} + ] + }, + { + "name": "active-bool", + "deleted": false, + "Attribute": [ + {"type":"domain","value":"active-object-bool.example","to_ids":true} + ] + }, + { + "name": "active-omitted", + "Attribute": [ + {"type":"domain","value":"active-object-omitted.example","to_ids":true} + ] + } + ] + } + }"#; + + let material = misp_import::parse_misp_document(raw, "misp:test", 60).unwrap(); + + assert_eq!(material.threats.len(), 2); + assert!(material + .threats + .iter() + .any(|threat| threat.value == "active-object-bool.example")); + assert!(material + .threats + .iter() + .any(|threat| threat.value == "active-object-omitted.example")); + assert_eq!(material.skipped_attributes, 3); +} From d67d4cc6e2c73d65d7fbc6515269060d66adbdcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:03:25 +0900 Subject: [PATCH 12/34] fix(misp): honor parent object deletion state --- src/misp_import.rs | 62 ++++++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 21 deletions(-) diff --git a/src/misp_import.rs b/src/misp_import.rs index 9226386..d95c78c 100644 --- a/src/misp_import.rs +++ b/src/misp_import.rs @@ -97,8 +97,15 @@ pub fn misp_material_from_value( .or_else(|| event.get("id").and_then(|i| i.as_str())) .unwrap_or("misp-event"); - for attr in collect_event_attributes(event) { - match materialize_attribute(attr, source, ttl_seconds, severity.clone(), event_label) { + for (attr, parent_active) in collect_event_attributes(event) { + match materialize_attribute( + attr, + source, + ttl_seconds, + severity.clone(), + event_label, + parent_active, + ) { AttributeOutcome::Mapped { threats: t, dnsbl: d, @@ -112,7 +119,14 @@ pub fn misp_material_from_value( } for (attr, severity) in loose_attributes { - match materialize_attribute(attr, source, ttl_seconds, severity, "misp-attribute") { + match materialize_attribute( + attr, + source, + ttl_seconds, + severity, + "misp-attribute", + true, + ) { AttributeOutcome::Mapped { threats: t, dnsbl: d, @@ -190,15 +204,29 @@ fn severity_from_event(event: &serde_json::Value) -> Severity { } } -fn collect_event_attributes(event: &serde_json::Value) -> Vec<&serde_json::Value> { +fn active_by_deleted_marker(deleted: Option<&serde_json::Value>) -> bool { + deleted + .map(|value| match value { + serde_json::Value::Bool(is_deleted) => !*is_deleted, + serde_json::Value::String(value) => { + value == "0" || value.eq_ignore_ascii_case("false") + } + serde_json::Value::Number(value) => value.as_u64() == Some(0), + _ => false, + }) + .unwrap_or(true) +} + +fn collect_event_attributes(event: &serde_json::Value) -> Vec<(&serde_json::Value, bool)> { let mut out = Vec::new(); if let Some(attrs) = event.get("Attribute").and_then(|a| a.as_array()) { - out.extend(attrs.iter()); + out.extend(attrs.iter().map(|attr| (attr, true))); } if let Some(objects) = event.get("Object").and_then(|o| o.as_array()) { for object in objects { + let parent_active = active_by_deleted_marker(object.get("deleted")); if let Some(attrs) = object.get("Attribute").and_then(|a| a.as_array()) { - out.extend(attrs.iter()); + out.extend(attrs.iter().map(|attr| (attr, parent_active))); } } } @@ -211,6 +239,7 @@ fn materialize_attribute( ttl_seconds: u64, severity: Severity, event_label: &str, + parent_active: bool, ) -> AttributeOutcome { // MISP's `to_ids` contract is affirmative evidence. Preserve the previously supported // scalar true spellings, but absent or malformed values cannot authorize enforcement. @@ -225,20 +254,11 @@ fn materialize_attribute( }) .unwrap_or(false); - // MISP publishes deletion state independently of `to_ids`: a formerly actionable - // attribute may retain `to_ids=true` while `deleted` marks it withdrawn. Omission is - // accepted for compatibility with active exports; any present unrecognized state fails - // closed instead of resurrecting withdrawn or structurally invalid enforcement data. - let active = attr - .get("deleted") - .map(|v| match v { - serde_json::Value::Bool(b) => !*b, - serde_json::Value::String(s) => s == "0" || s.eq_ignore_ascii_case("false"), - serde_json::Value::Number(n) => n.as_u64() == Some(0), - _ => false, - }) - .unwrap_or(true); - if !to_ids || !active { + // MISP publishes deletion state independently at both object and attribute scope. A + // nested attribute cannot override a withdrawn parent object. Omission remains active + // for compatibility; any present unrecognized deletion state fails closed. + let active = active_by_deleted_marker(attr.get("deleted")); + if !parent_active || !to_ids || !active { return AttributeOutcome::Skipped; } @@ -525,4 +545,4 @@ mod tests { ) .is_err()); } -} \ No newline at end of file +} From 92898fa07d99c653e8acd66bd3ee413307477ccb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:03:45 +0900 Subject: [PATCH 13/34] docs(misp): trace parent object lifecycle admission --- docs/doctoring/misp-to-ids-admission.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/misp-to-ids-admission.md b/docs/doctoring/misp-to-ids-admission.md index 1474c8d..017fe46 100644 --- a/docs/doctoring/misp-to-ids-admission.md +++ b/docs/doctoring/misp-to-ids-admission.md @@ -6,7 +6,7 @@ Wardnet converts selected MISP attributes into gateway enforcement material (`Th The MISP object schema defines `to_ids` as a boolean, and the MISP-STIX converter documents that `to_ids=True` is the detection-ready case that becomes an Indicator. MISP REST search can also return attributes regardless of their `to_ids` setting unless the caller filters them. Wardnet consequently cannot infer IDS authorization from mere presence in a MISP document. Missing, `false`, `null`, object, array, and otherwise unrecognized `to_ids` values fail closed and contribute to `skipped_attributes`; they do not produce enforcement rows. The previously supported explicit scalar compatibility spellings (`"1"`, case-insensitive `"true"`, numeric `1`) remain accepted so the repair does not broaden unrelated parser incompatibility. -MISP's ZeroMQ contract also exposes deletion independently of `to_ids`: examples use `deleted: false`/`"0"` for active material and set `deleted` to `1` when an attribute is deleted, with subscribers instructed to inspect the deletion marker. A positive `to_ids` value therefore cannot resurrect withdrawn material. Wardnet treats recognized `deleted=true`/`1` states as withdrawn; recognized `false`/`0` states and an omitted `deleted` member remain active for compatibility with ordinary exports. A present but structurally unrecognized deletion state fails closed rather than becoming an implicit active signal. +MISP's ZeroMQ contract exposes attribute deletion independently of `to_ids`: examples use `deleted: false`/`"0"` for active material and set `deleted` to `1` when an attribute is deleted, with subscribers instructed to inspect the deletion marker. MISP also gives an enclosing Object its own deletion lifecycle; the current event-object rendering path explicitly reads `object['deleted']` and marks the whole object deleted. A positive nested attribute `to_ids` value therefore cannot resurrect either a withdrawn attribute or a withdrawn parent Object. Wardnet treats recognized `deleted=true`/`1` states at either scope as withdrawn; recognized `false`/`0` states and an omitted `deleted` member remain active for compatibility with ordinary exports. A present but structurally unrecognized deletion state at either scope fails closed rather than becoming an implicit active signal. This is the security-design application of fail-safe defaults: permission to create enforcement material is established by positive evidence rather than by the absence of a denial. Saltzer and Schroeder (1975) describe that protection principle as defaulting to lack of permission unless conditions for access are established. For the parser boundary here, the analogous safe state is “do not create an enforcement indicator” when the upstream authorization signal is absent, withdrawn, or structurally invalid. @@ -14,11 +14,11 @@ The hostile regressions intentionally supply valid JSON whose admission members ## Alternatives considered -Keeping `unwrap_or(true)` for `to_ids` was rejected because an omitted field would silently become stronger evidence than MISP supplied. Treating arbitrary JSON values as true was rejected because a producer/schema defect would widen enforcement. Treating an unknown or malformed `deleted` value as active was rejected for the same reason: lifecycle ambiguity must not revive enforcement material. Rejecting the entire MISP document was also rejected for this bounded repair: `skipped_attributes` already provides a per-attribute fail-closed path and allows valid sibling indicators to remain usable while preserving input-quality evidence. A future contract version may remove the legacy scalar compatibility spellings, but that is a separate compatibility decision and is not required to close this defect. +Keeping `unwrap_or(true)` for `to_ids` was rejected because an omitted field would silently become stronger evidence than MISP supplied. Treating arbitrary JSON values as true was rejected because a producer/schema defect would widen enforcement. Treating an unknown or malformed `deleted` value as active was rejected for the same reason: lifecycle ambiguity must not revive enforcement material. Ignoring an enclosing MISP Object's deletion marker was rejected because it allows a nested attribute to outlive the producer's parent-object withdrawal decision. Rejecting the entire MISP document was also rejected for this bounded repair: `skipped_attributes` already provides a per-attribute fail-closed path and allows valid sibling indicators to remain usable while preserving input-quality evidence. A future contract version may remove the legacy scalar compatibility spellings, but that is a separate compatibility decision and is not required to close this defect. ## Verification contract -`tests/misp_to_ids_admission.rs` is the focused security regression. It requires object, array, `null`, and missing `to_ids` values to be skipped while an explicit boolean `true` control remains admitted. It also requires `deleted=true`, `deleted="1"`, and a structurally malformed deletion marker to be skipped even when `to_ids=true`, while `deleted=false`, `deleted="0"`, and omitted deletion state remain active. Existing unit coverage retains boolean `to_ids=false` rejection and the supported explicit scalar compatibility cases. Merge evidence must come from the exact current PR head and then-live Wardnet CI/fuzz/security/review gates; source reasoning or predecessor runs are not a substitute. +`tests/misp_to_ids_admission.rs` is the focused security regression. It requires object, array, `null`, and missing `to_ids` values to be skipped while an explicit boolean `true` control remains admitted. It also requires attribute-level `deleted=true`, `deleted="1"`, and a structurally malformed deletion marker to be skipped even when `to_ids=true`, while `deleted=false`, `deleted="0"`, and omitted deletion state remain active. The parent-object hostile case requires nested otherwise-actionable attributes to be skipped when the enclosing MISP Object is deleted or has a structurally ambiguous deletion marker; recognized active and omitted parent deletion state remain compatible. Existing unit coverage retains boolean `to_ids=false` rejection and the supported explicit scalar compatibility cases. Merge evidence must come from the exact current PR head and then-live Wardnet CI/fuzz/security/review gates; source reasoning or predecessor runs are not a substitute. ## Traceability and references @@ -28,6 +28,8 @@ MISP Project. (n.d.). *MISP-STIX converter*. GitHub. https://github.com/MISP/mis MISP Project. (n.d.). *MISP ZeroMQ documentation*. GitHub. https://github.com/MISP/misp-book/blob/main/misp-zmq/README.md +MISP Project. (2026). *Event object row rendering* (`app/View/Elements/Events/View/row_object.ctp`, commit `9294667a5b40e59ea42314c2aafa99086ce1d8e6`). GitHub. https://github.com/MISP/MISP/blob/9294667a5b40e59ea42314c2aafa99086ce1d8e6/app/View/Elements/Events/View/row_object.ctp + Saltzer, J. H., & Schroeder, M. D. (1975). The protection of information in computer systems. *Proceedings of the IEEE, 63*(9), 1278–1308. https://doi.org/10.1109/PROC.1975.9939 Author-hosted rendering: https://web.mit.edu/saltzer/www/publications/protection/index.html From a639e626764e2caf593266dbf94d2b030626bbaf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:15:51 +0900 Subject: [PATCH 14/34] test(feed): prove withdrawn DNSBL ownership leaks --- tests/threat_feed_dnsbl_ownership.rs | 179 +++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 tests/threat_feed_dnsbl_ownership.rs diff --git a/tests/threat_feed_dnsbl_ownership.rs b/tests/threat_feed_dnsbl_ownership.rs new file mode 100644 index 0000000..ba7c3cb --- /dev/null +++ b/tests/threat_feed_dnsbl_ownership.rs @@ -0,0 +1,179 @@ +use axum::{ + body::{Body, to_bytes}, + http::{Method, Request, StatusCode}, +}; +use serde_json::json; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppState, DnsblEntry, build_app}; + +fn json_request(method: Method, uri: &str, body: serde_json::Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .header("x-admin-token", "secret") + .body(Body::from(body.to_string())) + .unwrap() +} + +async fn dnsbl(app: &axum::Router) -> Vec { + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/dnsbl") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +fn feed_payload(feed_id: &str, source: &str, addresses: &[&str]) -> serde_json::Value { + json!({ + "feed_id": feed_id, + "source": source, + "ttl_seconds": 3600, + "threats": [], + "dnsbl": addresses.iter().map(|address| json!({ + "address": address, + "code": "127.0.0.2", + "reason": format!("{feed_id} verdict"), + "source": source, + "ttl_seconds": 3600, + "prefix_len": null + })).collect::>() + }) +} + +#[tokio::test] +async fn feed_refresh_reaps_dnsbl_entries_it_withdraws() { + let app = build_app(AppState::seeded(Some("secret".to_string()))); + + let first = app + .clone() + .oneshot(json_request( + Method::POST, + "/api/threat-feeds/import", + feed_payload("feed-a", "feed:a", &["203.0.113.210"]), + )) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::CREATED); + assert!(dnsbl(&app).iter().any(|entry| entry.address.to_string() == "203.0.113.210")); + + let refresh = app + .clone() + .oneshot(json_request( + Method::POST, + "/api/threat-feeds/import", + feed_payload("feed-a", "feed:a", &[]), + )) + .await + .unwrap(); + assert_eq!(refresh.status(), StatusCode::CREATED); + + assert!( + !dnsbl(&app) + .iter() + .any(|entry| entry.address.to_string() == "203.0.113.210"), + "withdrawn feed DNSBL material must not keep blocking after refresh" + ); +} + +#[tokio::test] +async fn feed_refresh_preserves_dnsbl_still_owned_by_another_feed() { + let app = build_app(AppState::seeded(Some("secret".to_string()))); + let address = "203.0.113.211"; + + for (feed_id, source) in [("feed-a", "feed:a"), ("feed-b", "feed:b")] { + let response = app + .clone() + .oneshot(json_request( + Method::POST, + "/api/threat-feeds/import", + feed_payload(feed_id, source, &[address]), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + } + + let refresh = app + .clone() + .oneshot(json_request( + Method::POST, + "/api/threat-feeds/import", + feed_payload("feed-a", "feed:a", &[]), + )) + .await + .unwrap(); + assert_eq!(refresh.status(), StatusCode::CREATED); + + assert!( + dnsbl(&app) + .iter() + .any(|entry| entry.address.to_string() == address), + "one feed cannot delete a DNSBL address still claimed by another feed" + ); +} + +#[tokio::test] +async fn feed_refresh_preserves_operator_managed_dnsbl_payload() { + let app = build_app(AppState::seeded(Some("secret".to_string()))); + let address = "203.0.113.212"; + + let feed = app + .clone() + .oneshot(json_request( + Method::POST, + "/api/threat-feeds/import", + feed_payload("feed-a", "feed:a", &[address]), + )) + .await + .unwrap(); + assert_eq!(feed.status(), StatusCode::CREATED); + + let operator = app + .clone() + .oneshot(json_request( + Method::POST, + "/api/dnsbl", + json!({ + "address": address, + "code": "127.0.0.77", + "reason": "operator-reviewed exception payload", + "source": "operator", + "ttl_seconds": 86400, + "prefix_len": null + }), + )) + .await + .unwrap(); + assert_eq!(operator.status(), StatusCode::CREATED); + + let refresh = app + .clone() + .oneshot(json_request( + Method::POST, + "/api/threat-feeds/import", + feed_payload("feed-a", "feed:a", &[]), + )) + .await + .unwrap(); + assert_eq!(refresh.status(), StatusCode::CREATED); + + let entries = dnsbl(&app); + let entry = entries + .iter() + .find(|entry| entry.address.to_string() == address) + .expect("operator-owned DNSBL entry must survive feed withdrawal"); + assert_eq!(entry.code, "127.0.0.77"); + assert_eq!(entry.reason, "operator-reviewed exception payload"); + assert_eq!(entry.source, "operator"); + assert_eq!(entry.ttl_seconds, 86400); +} From d3e09bb20f7ec7962b1406fa0090e0903f9d4956 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:21:50 +0900 Subject: [PATCH 15/34] docs(feed): define DNSBL snapshot ownership invariant --- docs/doctoring/threat-feed-dnsbl-ownership.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/doctoring/threat-feed-dnsbl-ownership.md diff --git a/docs/doctoring/threat-feed-dnsbl-ownership.md b/docs/doctoring/threat-feed-dnsbl-ownership.md new file mode 100644 index 0000000..97890c9 --- /dev/null +++ b/docs/doctoring/threat-feed-dnsbl-ownership.md @@ -0,0 +1,63 @@ +# Threat-feed DNSBL snapshot ownership + +## Decision boundary + +`POST /api/threat-feeds/import` is a snapshot-reconciliation boundary, not an append-only DNSBL ingest path. A feed refresh may withdraw a previously published address. Wardnet must therefore distinguish four facts that the current global `Vec` cannot express by itself: + +1. the stable DNSBL identity currently used by `upsert_dnsbl`; +2. which feed snapshots still claim that identity; +3. whether an operator independently owns that identity; and +4. the current effective payload stored for that identity. + +The stable identity for this repair is the same identity the mutation primitive already enforces: IP `address`. Introducing a different ownership key would make reconciliation disagree with `upsert_dnsbl` and would permit two logical owners to mutate one physical row under incompatible identities. + +## Required state model + +The durable `AppData` authority must carry explicit DNSBL ownership rather than infer it from source strings, TTL, threat indicators, audit logs, or adapter-specific conventions. + +- Add a serializable/hashable `DnsblEntryKey` whose identity matches the global DNSBL upsert identity. +- Add `dnsbl_keys` to each `ThreatFeedOwnership`, with `#[serde(default)]` so persisted predecessor state migrates without a destructive rewrite. +- Add `operator_dnsbl_keys` to `AppData`, also `#[serde(default)]`. +- `/api/dnsbl` writes mark the address as operator-owned before replacing the effective DNSBL payload. +- A feed import replaces that feed's threat and DNSBL ownership sets as one snapshot mutation, then removes each previously-owned DNSBL row only when the address is absent from the new snapshot, absent from every other feed ownership set, and absent from operator ownership. +- Feed upsert must not overwrite an operator-owned payload at the same stable address. This mirrors the existing threat-indicator rule that operator-managed payload wins over feed refresh. + +Ownership metadata is internal control-plane state. It does not become threat intelligence, does not become a synthetic `ThreatIndicator`, and must not be encoded into `source`, audit-log text, or another bounded context. Those shortcuts were rejected because they would make authority implicit and break DDD naming/semantic boundaries. + +## Replay, persistence, and idempotency + +A repeated identical feed snapshot must leave both ownership and effective DNSBL state unchanged apart from the feed freshness timestamp already owned by the import path. Restarting from persisted `AppData` must retain enough ownership to make the next refresh deterministic; an in-memory sidecar is therefore not sufficient. A refresh of feed A must never delete an address still owned by feed B. A later operator upsert at an address previously owned by a feed must survive withdrawal of that feed without payload rollback. + +The repair must remain inside Wardnet's shared threat-feed admission/control-plane path. MISP, STIX/TAXII, OpenCTI, KEV, and other adapters provide feed material but do not copy or reimplement reconciliation. This is why the valid review finding is repaired at `apply_threat_feed_import`, not inside `misp_import.rs`. + +## Hostile RED contract + +`tests/threat_feed_dnsbl_ownership.rs` is the focused public-API regression. Exact RED `a639e626764e2caf593266dbf94d2b030626bbaf` proves the current state model lacks the first required cleanup behavior and cannot safely prove operator ownership: + +- import feed A with one DNSBL address, refresh A with an empty DNSBL snapshot, and require the withdrawn row to disappear; +- import the same address from feeds A and B, withdraw it from A, and require the row to remain because B still owns it; +- import an address from a feed, overwrite that address through the operator `/api/dnsbl` surface, withdraw the feed, and require the operator payload to survive byte-for-byte at the domain-field level. + +GREEN requires all three tests plus existing threat-feed ownership regressions to pass on the same exact head. A solution that merely stops gateway scoring while leaving stale `/api/dnsbl` state, relies on TTL expiry, synthesizes hidden threat indicators, or keeps ownership only in process memory does not satisfy the contract. + +## Operational evidence + +The current central runner queue can delay remote execution, but queued/pre-checkout state is non-passing evidence rather than a reason to weaken this invariant. Merge remains prohibited until the exact repaired head obtains the repository-owned CI/Fuzz and all then-live security, coverage, SBOM, provenance, review-thread, and branch-integrity gates required by the protected ruleset. + +## Security rationale + +This is a fail-safe lifecycle requirement. Revocation or withdrawal must converge the enforcement set toward less authority, not leave an orphaned deny decision whose producer no longer claims it. The rule also prevents one producer from deleting another producer's still-valid deny state and prevents automated feed refresh from erasing a later human/operator decision. + +The general protection rationale remains the fail-safe-default principle documented for the adjacent MISP admission repair: authority must be established by explicit positive state, and ambiguous or withdrawn authority must not silently continue enforcement. See `docs/doctoring/misp-to-ids-admission.md` and its Saltzer–Schroeder traceability. The hostile regression style follows the repository's existing Manès et al. fuzzing-survey traceability and tests semantic lifecycle corruption rather than malformed JSON alone. + +## Acceptance + +- `DnsblEntryKey` and its ownership fields are explicit, persisted, serde-defaulted, and code/API tests cover predecessor-state deserialization. +- ownership replacement and stale-row removal happen inside the same `mutate_and_persist` transaction as the feed snapshot; +- same-address multiple-feed ownership prevents premature deletion; +- operator ownership prevents feed deletion and feed payload overwrite; +- repeated snapshots are idempotent; +- `upserted_dnsbl` reports actual feed writes, not merely requested input length when an operator-owned row is preserved; +- existing threat ownership semantics remain unchanged; +- no adapter-specific copy of reconciliation logic is introduced; +- exact-current CI/Fuzz/security/coverage/SBOM/provenance/review evidence is terminal GREEN before merge. From 5071b51c5fdd668396ecd49a98f515433917ea9e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:46:45 +0900 Subject: [PATCH 16/34] fix(feed): persist DNSBL snapshot ownership --- crates/waf-ids-core/src/lib.rs | 983 ++++++--------------------------- 1 file changed, 173 insertions(+), 810 deletions(-) diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index f9673e0..f620d6c 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -14,6 +14,8 @@ pub struct AppData { #[serde(default)] pub operator_threat_keys: Vec, pub dnsbl: Vec, + #[serde(default)] + pub operator_dnsbl_keys: Vec, pub events: Vec, pub next_event_id: u64, #[serde(default)] @@ -55,6 +57,7 @@ impl AppData { ttl_seconds: 300, prefix_len: None, }], + operator_dnsbl_keys: Vec::new(), events: Vec::new(), next_event_id: 1, audit_logs: Vec::new(), @@ -138,6 +141,13 @@ pub struct DnsblEntry { pub prefix_len: Option, } +/// Stable DNSBL ownership identity. It mirrors [`upsert_dnsbl`], which stores +/// one effective DNSBL row per IP address regardless of producer payload. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct DnsblEntryKey { + pub address: IpAddr, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CommercialProfile { pub tenant_id: String, @@ -189,6 +199,8 @@ pub struct ThreatFeedStatus { pub struct ThreatFeedOwnership { pub feed_id: String, pub threat_keys: Vec, + #[serde(default)] + pub dnsbl_keys: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -491,9 +503,6 @@ pub fn validate_threat_feed_import(feed: &ThreatFeedImport) -> Result<(), &'stat if feed.ttl_seconds == 0 { return Err("threat feed ttl_seconds must be greater than 0"); } - if feed.threats.is_empty() && feed.dnsbl.is_empty() { - return Err("threat feed must include at least one threat or DNSBL entry"); - } for threat in &feed.threats { validate_threat(threat)?; } @@ -560,21 +569,37 @@ pub fn threat_indicator_key(indicator: &ThreatIndicator) -> ThreatIndicatorKey { } } +/// Returns the durable ownership identity used by DNSBL snapshot reconciliation. +pub fn dnsbl_entry_key(entry: &DnsblEntry) -> DnsblEntryKey { + DnsblEntryKey { + address: entry.address, + } +} + +/// Replaces one feed's complete threat and DNSBL claim sets and returns the +/// persisted predecessor snapshot so callers can retire claims that disappeared. pub fn replace_threat_feed_ownership( ownership: &mut Vec, feed_id: String, threat_keys: Vec, -) -> Vec { + dnsbl_keys: Vec, +) -> ThreatFeedOwnership { if let Some(existing) = ownership.iter_mut().find(|item| item.feed_id == feed_id) { - let previous = existing.threat_keys.clone(); + let previous = existing.clone(); existing.threat_keys = threat_keys; + existing.dnsbl_keys = dnsbl_keys; previous } else { ownership.push(ThreatFeedOwnership { - feed_id, + feed_id: feed_id.clone(), threat_keys, + dnsbl_keys, }); - Vec::new() + ThreatFeedOwnership { + feed_id, + threat_keys: Vec::new(), + dnsbl_keys: Vec::new(), + } } } @@ -617,168 +642,32 @@ pub struct BuiltinSignature { /// false positives; operator [`ThreatIndicator`]s cover site-specific payloads. pub fn builtin_signatures() -> &'static [BuiltinSignature] { const SIGS: &[BuiltinSignature] = &[ - // SQL injection - BuiltinSignature { - id: "sqli-union-select", - class: "sqli", - pattern: "union select", - severity: Severity::High, - }, - BuiltinSignature { - id: "sqli-or-tautology", - class: "sqli", - pattern: "or 1=1", - severity: Severity::High, - }, - BuiltinSignature { - id: "sqli-quoted-tautology", - class: "sqli", - pattern: "' or '", - severity: Severity::High, - }, - BuiltinSignature { - id: "sqli-comment", - class: "sqli", - pattern: "'--", - severity: Severity::Medium, - }, - BuiltinSignature { - id: "sqli-sleep", - class: "sqli", - pattern: "sleep(", - severity: Severity::High, - }, - BuiltinSignature { - id: "sqli-benchmark", - class: "sqli", - pattern: "benchmark(", - severity: Severity::High, - }, - BuiltinSignature { - id: "sqli-waitfor", - class: "sqli", - pattern: "waitfor delay", - severity: Severity::High, - }, - BuiltinSignature { - id: "sqli-info-schema", - class: "sqli", - pattern: "information_schema", - severity: Severity::Medium, - }, - // Cross-site scripting - BuiltinSignature { - id: "xss-script-tag", - class: "xss", - pattern: " Vec { pub fn anomaly_signal(haystack: &str) -> Option<(u16, String)> { let mut score = 0u16; let mut reasons = Vec::new(); - - // Signal 1: shell/markup metacharacter density. const META: &str = "<>'\"();|&$`"; let suspicious = haystack.chars().filter(|c| META.contains(*c)).count(); let ratio = suspicious as f64 / haystack.chars().count().max(1) as f64; if suspicious >= 6 && ratio >= 0.08 { score += 15; - reasons.push(format!( - "{suspicious} metacharacters ({:.0}% density)", - ratio * 100.0 - )); + reasons.push(format!("{suspicious} metacharacters ({:.0}% density)", ratio * 100.0)); } - - // Signal 2: high Shannon entropy over a non-trivial payload — a marker of - // encoded/obfuscated content (base64 blobs, packed exploit strings) that - // signature matching misses. Length-gated so short requests never trip it. if haystack.len() >= 40 { let entropy = shannon_entropy(haystack.as_bytes()); if entropy >= 4.5 { @@ -837,29 +717,17 @@ pub fn anomaly_signal(haystack: &str) -> Option<(u16, String)> { reasons.push(format!("high entropy {entropy:.1} bits/byte")); } } - - if score == 0 { - None - } else { - Some((score, format!("anomaly heuristic: {}", reasons.join("; ")))) - } + if score == 0 { None } else { Some((score, format!("anomaly heuristic: {}", reasons.join("; ")))) } } -/// Shannon entropy in bits per byte of a non-empty byte slice. fn shannon_entropy(bytes: &[u8]) -> f64 { let mut counts = [0u32; 256]; - for &byte in bytes { - counts[byte as usize] += 1; - } + for &byte in bytes { counts[byte as usize] += 1; } let total = bytes.len() as f64; - counts - .iter() - .filter(|&&count| count > 0) - .map(|&count| { - let p = count as f64 / total; - -p * p.log2() - }) - .sum() + counts.iter().filter(|&&count| count > 0).map(|&count| { + let p = count as f64 / total; + -p * p.log2() + }).sum() } pub fn score_request( @@ -870,70 +738,43 @@ pub fn score_request( threats: &[ThreatIndicator], dnsbl: &[DnsblEntry], ) -> ScoredRequest { - let decoded_query = query - .map(|value| percent_decode_str(value).decode_utf8_lossy()) - .unwrap_or_default(); + let decoded_query = query.map(|value| percent_decode_str(value).decode_utf8_lossy()).unwrap_or_default(); let haystack = format!("{}?{} {}", path, decoded_query, body).to_lowercase(); let mut score: u16 = 0; let mut reasons = Vec::new(); - - // Built-in OWASP-shape signatures (no operator configuration required). for sig in builtin_signatures() { if haystack.contains(sig.pattern) { score = score.saturating_add(severity_score(&sig.severity)); reasons.push(format!("builtin {} rule {}", sig.class, sig.id)); } } - - // Operator-configured threat indicators (and engine-fed IP/path hits). for indicator in threats { let kind = indicator.indicator_type.to_ascii_lowercase(); let matched = if matches!(kind.as_str(), "ip" | "client_ip" | "source_ip" | "src_ip") { client_ip.is_some_and(|ip| indicator.value.parse::().ok() == Some(ip)) } else if kind == "cve" { - // A CVE identifier is vulnerability-catalog metadata (e.g. from a - // CISA KEV import), not a request-content observable: it can - // legitimately appear in a vulnerability-management or security - // tool's own traffic (`/api/cve/CVE-2021-44228`), so it must never - // drive content-substring scoring. It stays visible via the - // threat-indicator, feed-freshness, and buyer-evidence APIs. false } else { haystack.contains(&indicator.value.to_lowercase()) }; if matched { score = score.saturating_add(severity_score(&indicator.severity)); - reasons.push(format!( - "{} indicator from {}", - indicator.indicator_type, indicator.source - )); + reasons.push(format!("{} indicator from {}", indicator.indicator_type, indicator.source)); } } - - // DNSBL client reputation. if let Some(ip) = client_ip && let Some(entry) = dnsbl.iter().find(|entry| dnsbl_matches(entry, ip)) { score = score.saturating_add(100); - reasons.push(format!( - "DNSBL match {} from {}", - entry.reason, entry.source - )); + reasons.push(format!("DNSBL match {} from {}", entry.reason, entry.source)); } - - // Behavioral anomaly heuristic (first-tier AI SOC signal). if let Some((anomaly, reason)) = anomaly_signal(&haystack) { score = score.saturating_add(anomaly); reasons.push(reason); } - ScoredRequest { score, - reason: if reasons.is_empty() { - "no matching indicator".to_string() - } else { - reasons.join("; ") - }, + reason: if reasons.is_empty() { "no matching indicator".to_string() } else { reasons.join("; ") }, } } @@ -946,22 +787,8 @@ pub fn severity_score(severity: &Severity) -> u16 { } } -/// Fixed-window rate-limit arithmetic for one client key. Given the current -/// window state `(window_start, count)`, returns `(allowed, new_window_start, -/// new_count)`. `limit == 0` disables limiting (always allowed). -/// -/// ponytail: fixed window — permits up to ~2x `limit` across a boundary; swap -/// for a sliding window if that burst matters. -pub fn rate_limit_step( - now_unix: u64, - window_start: u64, - count: u32, - limit: u32, - window_secs: u64, -) -> (bool, u64, u32) { - if limit == 0 { - return (true, window_start, count); - } +pub fn rate_limit_step(now_unix: u64, window_start: u64, count: u32, limit: u32, window_secs: u64) -> (bool, u64, u32) { + if limit == 0 { return (true, window_start, count); } if now_unix.saturating_sub(window_start) >= window_secs.max(1) { (true, now_unix, 1) } else if count < limit { @@ -978,31 +805,18 @@ pub fn enforce_event_limit(data: &mut AppData, limit: usize) { } } -pub fn threat_feed_freshness_snapshot( - feeds: &[ThreatFeedStatus], - now_unix: u64, -) -> Vec { - feeds - .iter() - .map(|feed| { - let expires_at_unix = feed.last_updated_unix.saturating_add(feed.ttl_seconds); - ThreatFeedFreshness { - feed_id: feed.feed_id.clone(), - source: feed.source.clone(), - last_updated_unix: feed.last_updated_unix, - threat_count: feed.threat_count, - dnsbl_count: feed.dnsbl_count, - ttl_seconds: feed.ttl_seconds, - expires_at_unix, - stale: expires_at_unix <= now_unix, - } - }) - .collect() +pub fn threat_feed_freshness_snapshot(feeds: &[ThreatFeedStatus], now_unix: u64) -> Vec { + feeds.iter().map(|feed| { + let expires_at_unix = feed.last_updated_unix.saturating_add(feed.ttl_seconds); + ThreatFeedFreshness { + feed_id: feed.feed_id.clone(), source: feed.source.clone(), last_updated_unix: feed.last_updated_unix, + threat_count: feed.threat_count, dnsbl_count: feed.dnsbl_count, ttl_seconds: feed.ttl_seconds, + expires_at_unix, stale: expires_at_unix <= now_unix, + } + }).collect() } -pub fn kpi_snapshot(data: &AppData) -> SocKpiSnapshot { - kpi_snapshot_at(data, unix_now()) -} +pub fn kpi_snapshot(data: &AppData) -> SocKpiSnapshot { kpi_snapshot_at(data, unix_now()) } pub fn kpi_snapshot_at(data: &AppData, now_unix: u64) -> SocKpiSnapshot { let feed_freshness = threat_feed_freshness_snapshot(&data.threat_feeds, now_unix); @@ -1014,169 +828,62 @@ pub fn kpi_snapshot_at(data: &AppData, now_unix: u64) -> SocKpiSnapshot { fresh_threat_feed_count: feed_freshness.iter().filter(|feed| !feed.stale).count(), stale_threat_feed_count: feed_freshness.iter().filter(|feed| feed.stale).count(), event_count: data.events.len(), - blocked_event_count: data - .events - .iter() - .filter(|event| event.action == "blocked") - .count(), - monitor_event_count: data - .events - .iter() - .filter(|event| event.action == "monitored") - .count(), + blocked_event_count: data.events.iter().filter(|event| event.action == "blocked").count(), + monitor_event_count: data.events.iter().filter(|event| event.action == "monitored").count(), audit_log_count: data.audit_logs.len(), gateway_mode: "rust-first edge gateway program baseline".to_string(), } } -/// Renders a [`SocKpiSnapshot`] as Prometheus text exposition (0.0.4). Counters -/// are current-state gauges, so a `gauge` type is correct for scrapers. No -/// dependency — the format is a few lines of text. pub fn prometheus_exposition(kpi: &SocKpiSnapshot) -> String { let metrics: [(&str, &str, usize); 10] = [ - ( - "waf_ids_routes", - "Configured gateway routes.", - kpi.route_count, - ), - ( - "waf_ids_threat_indicators", - "Operator threat indicators loaded.", - kpi.threat_indicator_count, - ), - ( - "waf_ids_dnsbl_entries", - "DNSBL reputation entries.", - kpi.dnsbl_entry_count, - ), - ( - "waf_ids_threat_feeds", - "Imported threat feeds.", - kpi.threat_feed_count, - ), - ( - "waf_ids_threat_feeds_fresh", - "Threat feeds within their TTL.", - kpi.fresh_threat_feed_count, - ), - ( - "waf_ids_threat_feeds_stale", - "Threat feeds past their TTL.", - kpi.stale_threat_feed_count, - ), - ( - "waf_ids_security_events", - "Total recorded security events.", - kpi.event_count, - ), - ( - "waf_ids_security_events_blocked", - "Security events with a blocked action.", - kpi.blocked_event_count, - ), - ( - "waf_ids_security_events_monitored", - "Security events with a monitored action.", - kpi.monitor_event_count, - ), - ( - "waf_ids_audit_log_entries", - "Recorded management audit-log entries.", - kpi.audit_log_count, - ), + ("waf_ids_routes", "Configured gateway routes.", kpi.route_count), + ("waf_ids_threat_indicators", "Operator threat indicators loaded.", kpi.threat_indicator_count), + ("waf_ids_dnsbl_entries", "DNSBL reputation entries.", kpi.dnsbl_entry_count), + ("waf_ids_threat_feeds", "Imported threat feeds.", kpi.threat_feed_count), + ("waf_ids_threat_feeds_fresh", "Threat feeds within their TTL.", kpi.fresh_threat_feed_count), + ("waf_ids_threat_feeds_stale", "Threat feeds past their TTL.", kpi.stale_threat_feed_count), + ("waf_ids_security_events", "Total recorded security events.", kpi.event_count), + ("waf_ids_security_events_blocked", "Security events with a blocked action.", kpi.blocked_event_count), + ("waf_ids_security_events_monitored", "Security events with a monitored action.", kpi.monitor_event_count), + ("waf_ids_audit_log_entries", "Recorded management audit-log entries.", kpi.audit_log_count), ]; let mut out = String::new(); for (name, help, value) in metrics { - out.push_str(&format!( - "# HELP {name} {help}\n# TYPE {name} gauge\n{name} {value}\n" - )); + out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} gauge\n{name} {value}\n")); } out } -pub fn commercial_readiness_snapshot(data: &AppData) -> CommercialReadiness { - commercial_readiness_snapshot_at(data, unix_now()) -} +pub fn commercial_readiness_snapshot(data: &AppData) -> CommercialReadiness { commercial_readiness_snapshot_at(data, unix_now()) } pub fn commercial_readiness_snapshot_at(data: &AppData, now_unix: u64) -> CommercialReadiness { - let license_ready = matches!( - data.commercial.license_status, - LicenseStatus::Active | LicenseStatus::Evaluation - ) && data - .commercial - .license_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()) - && data - .commercial - .licensee - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - let commercial_value_ready = data - .commercial - .annual_contract_value_krw - .is_some_and(|value| value >= TARGET_SALE_VALUE_KRW); + let license_ready = matches!(data.commercial.license_status, LicenseStatus::Active | LicenseStatus::Evaluation) + && data.commercial.license_id.as_deref().is_some_and(|value| !value.trim().is_empty()) + && data.commercial.licensee.as_deref().is_some_and(|value| !value.trim().is_empty()); + let commercial_value_ready = data.commercial.annual_contract_value_krw.is_some_and(|value| value >= TARGET_SALE_VALUE_KRW); let threat_feed_ready = threat_feed_freshness_snapshot(&data.threat_feeds, now_unix) - .iter() - .any(|feed| !feed.stale && (feed.threat_count > 0 || feed.dnsbl_count > 0)); + .iter().any(|feed| !feed.stale && (feed.threat_count > 0 || feed.dnsbl_count > 0)); let route_ready = data.routes.iter().any(|route| route.enabled); let dnsbl_ready = !data.dnsbl.is_empty(); let support_evidence_ready = !data.events.is_empty(); - let checks = vec![ - readiness_check( - "license", - license_ready, - "active/evaluation tenant license metadata is present", - ), - readiness_check( - "contract_value", - commercial_value_ready, - "annual contract value meets the 2B KRW sale target", - ), - readiness_check( - "threat_feed_updates", - threat_feed_ready, - "at least one imported threat feed is fresh within its TTL", - ), - readiness_check( - "gateway_enforcement", - route_ready, - "at least one enabled gateway route is configured", - ), - readiness_check( - "dnsbl_publication", - dnsbl_ready, - "DNSBL entries are available for zone export", - ), - readiness_check( - "support_evidence", - support_evidence_ready, - "security event evidence is available for a support bundle", - ), + readiness_check("license", license_ready, "active/evaluation tenant license metadata is present"), + readiness_check("contract_value", commercial_value_ready, "annual contract value meets the 2B KRW sale target"), + readiness_check("threat_feed_updates", threat_feed_ready, "at least one imported threat feed is fresh within its TTL"), + readiness_check("gateway_enforcement", route_ready, "at least one enabled gateway route is configured"), + readiness_check("dnsbl_publication", dnsbl_ready, "DNSBL entries are available for zone export"), + readiness_check("support_evidence", support_evidence_ready, "security event evidence is available for a support bundle"), ]; - let blockers: Vec = checks - .iter() - .filter(|check| check.status == ReadinessStatus::Fail) - .map(|check| check.id.clone()) - .collect(); + let blockers: Vec = checks.iter().filter(|check| check.status == ReadinessStatus::Fail).map(|check| check.id.clone()).collect(); let ready_for_enterprise_sale = blockers.is_empty(); - CommercialReadiness { target_sale_value_krw: TARGET_SALE_VALUE_KRW, ready_for_enterprise_sale, - readiness_level: if ready_for_enterprise_sale { - "sale_ready".to_string() - } else { - "implementation_required".to_string() - }, + readiness_level: if ready_for_enterprise_sale { "sale_ready".to_string() } else { "implementation_required".to_string() }, blockers, checks, - deployment_assets: vec![ - "Dockerfile".to_string(), - "deploy/docker-compose.yml".to_string(), - "deploy/kubernetes/waf-ids-ai-soc.yaml".to_string(), - ], + deployment_assets: vec!["Dockerfile".to_string(), "deploy/docker-compose.yml".to_string(), "deploy/kubernetes/waf-ids-ai-soc.yaml".to_string()], buyer_evidence: vec![ "docs/commercial/20b-krw-sale-readiness.md".to_string(), "docs/commercial/buyer-due-diligence.md".to_string(), @@ -1186,14 +893,11 @@ pub fn commercial_readiness_snapshot_at(data: &AppData, now_unix: u64) -> Commer } } -pub fn buyer_evidence_manifest(data: &AppData) -> BuyerEvidenceManifest { - buyer_evidence_manifest_at(data, unix_now()) -} +pub fn buyer_evidence_manifest(data: &AppData) -> BuyerEvidenceManifest { buyer_evidence_manifest_at(data, unix_now()) } pub fn buyer_evidence_manifest_at(data: &AppData, now_unix: u64) -> BuyerEvidenceManifest { let readiness = commercial_readiness_snapshot_at(data, now_unix); let kpis = kpi_snapshot_at(data, now_unix); - BuyerEvidenceManifest { generated_at_unix: now_unix, target_sale_value_krw: readiness.target_sale_value_krw, @@ -1225,170 +929,37 @@ pub fn buyer_evidence_manifest_at(data: &AppData, now_unix: u64) -> BuyerEvidenc fn buyer_evidence_endpoints() -> Vec { vec![ - buyer_evidence_endpoint( - "health", - "GET", - "/healthz", - "application/json", - "runtime health, persistence mode, DNSBL origin, and event retention limit", - true, - ), - buyer_evidence_endpoint( - "license", - "GET", - "/api/commercial/license", - "application/json", - "tenant, edition, license, support, node count, and contract metadata", - true, - ), - buyer_evidence_endpoint( - "readiness", - "GET", - "/api/commercial/readiness", - "application/json", - "2B KRW readiness checks and explicit blockers", - true, - ), - buyer_evidence_endpoint( - "evidence_manifest", - "GET", - "/api/commercial/evidence-manifest", - "application/json", - "buyer-verifiable evidence map for runtime APIs, docs, and deployment assets", - true, - ), - buyer_evidence_endpoint( - "feed_freshness", - "GET", - "/api/threat-feeds/freshness", - "application/json", - "fresh and stale threat-feed evidence from TTL and last update time", - true, - ), - buyer_evidence_endpoint( - "soc_event_export", - "GET", - "/api/events.ndjson", - "application/x-ndjson", - "one-security-event-per-line SOC/SIEM ingestion evidence", - true, - ), - buyer_evidence_endpoint( - "management_audit_logs", - "GET", - "/api/audit-logs", - "application/json", - "admin write history for buyer due-diligence without admin secrets", - true, - ), - buyer_evidence_endpoint( - "support_bundle", - "GET", - "/api/support-bundle", - "application/json", - "support and due-diligence handoff package without admin secrets", - true, - ), - buyer_evidence_endpoint( - "dnsbl_zone", - "GET", - "/dnsbl/zone", - "text/plain", - "RFC 5782-style DNSBL zone export for buyer lab DNS validation", - true, - ), - buyer_evidence_endpoint( - "suricata_eve_ingest", - "POST", - "/api/ids/suricata/eve", - "application/json", - "Suricata EVE JSON/NDJSON alert ingest into SOC security events (admin-auth)", - false, - ), - buyer_evidence_endpoint( - "coraza_audit_ingest", - "POST", - "/api/waf/coraza/audit", - "application/json", - "Coraza/OWASP CRS WAF audit JSON/NDJSON ingest into SOC security events (admin-auth)", - false, - ), - buyer_evidence_endpoint( - "stix_indicator_ingest", - "POST", - "/api/threat-intel/stix", - "application/json", - "STIX 2.x indicator/bundle ingest into threat indicators and DNSBL (admin-auth)", - false, - ), - buyer_evidence_endpoint( - "misp_event_ingest", - "POST", - "/api/threat-intel/misp", - "application/json", - "MISP Event/attribute JSON ingest into threat indicators and DNSBL (admin-auth)", - false, - ), - buyer_evidence_endpoint( - "taxii_collection_poll", - "POST", - "/api/threat-intel/taxii/poll", - "application/json", - "TAXII 2.1 collection objects poll into threat indicators and DNSBL (admin-auth)", - false, - ), - buyer_evidence_endpoint( - "opencti_observable_ingest", - "POST", - "/api/threat-intel/opencti", - "application/json", - "OpenCTI observable/indicator JSON ingest into threat indicators and DNSBL (admin-auth)", - false, - ), - buyer_evidence_endpoint( - "cisa_kev_ingest", - "POST", - "/api/threat-intel/cisa-kev", - "application/json", - "CISA Known Exploited Vulnerabilities catalog pull into CVE threat indicators (admin-auth)", - false, - ), + buyer_evidence_endpoint("health", "GET", "/healthz", "application/json", "runtime health, persistence mode, DNSBL origin, and event retention limit", true), + buyer_evidence_endpoint("license", "GET", "/api/commercial/license", "application/json", "tenant, edition, license, support, node count, and contract metadata", true), + buyer_evidence_endpoint("readiness", "GET", "/api/commercial/readiness", "application/json", "2B KRW readiness checks and explicit blockers", true), + buyer_evidence_endpoint("evidence_manifest", "GET", "/api/commercial/evidence-manifest", "application/json", "buyer-verifiable evidence map for runtime APIs, docs, and deployment assets", true), + buyer_evidence_endpoint("feed_freshness", "GET", "/api/threat-feeds/freshness", "application/json", "fresh and stale threat-feed evidence from TTL and last update time", true), + buyer_evidence_endpoint("soc_event_export", "GET", "/api/events.ndjson", "application/x-ndjson", "one-security-event-per-line SOC/SIEM ingestion evidence", true), + buyer_evidence_endpoint("management_audit_logs", "GET", "/api/audit-logs", "application/json", "admin write history for buyer due-diligence without admin secrets", true), + buyer_evidence_endpoint("support_bundle", "GET", "/api/support-bundle", "application/json", "support and due-diligence handoff package without admin secrets", true), + buyer_evidence_endpoint("dnsbl_zone", "GET", "/dnsbl/zone", "text/plain", "RFC 5782-style DNSBL zone export for buyer lab DNS validation", true), + buyer_evidence_endpoint("suricata_eve_ingest", "POST", "/api/ids/suricata/eve", "application/json", "Suricata EVE JSON/NDJSON alert ingest into SOC security events (admin-auth)", false), + buyer_evidence_endpoint("coraza_audit_ingest", "POST", "/api/waf/coraza/audit", "application/json", "Coraza/OWASP CRS WAF audit JSON/NDJSON ingest into SOC security events (admin-auth)", false), + buyer_evidence_endpoint("stix_indicator_ingest", "POST", "/api/threat-intel/stix", "application/json", "STIX 2.x indicator/bundle ingest into threat indicators and DNSBL (admin-auth)", false), + buyer_evidence_endpoint("misp_event_ingest", "POST", "/api/threat-intel/misp", "application/json", "MISP Event/attribute JSON ingest into threat indicators and DNSBL (admin-auth)", false), + buyer_evidence_endpoint("taxii_collection_poll", "POST", "/api/threat-intel/taxii/poll", "application/json", "TAXII 2.1 collection objects poll into threat indicators and DNSBL (admin-auth)", false), + buyer_evidence_endpoint("opencti_observable_ingest", "POST", "/api/threat-intel/opencti", "application/json", "OpenCTI observable/indicator JSON ingest into threat indicators and DNSBL (admin-auth)", false), + buyer_evidence_endpoint("cisa_kev_ingest", "POST", "/api/threat-intel/cisa-kev", "application/json", "CISA Known Exploited Vulnerabilities catalog pull into CVE threat indicators (admin-auth)", false), ] } -fn buyer_evidence_endpoint( - id: &str, - method: &str, - path: &str, - content_type: &str, - proves: &str, - required_for_sale: bool, -) -> BuyerEvidenceEndpoint { - BuyerEvidenceEndpoint { - id: id.to_string(), - method: method.to_string(), - path: path.to_string(), - content_type: content_type.to_string(), - proves: proves.to_string(), - required_for_sale, - } +fn buyer_evidence_endpoint(id: &str, method: &str, path: &str, content_type: &str, proves: &str, required_for_sale: bool) -> BuyerEvidenceEndpoint { + BuyerEvidenceEndpoint { id: id.to_string(), method: method.to_string(), path: path.to_string(), content_type: content_type.to_string(), proves: proves.to_string(), required_for_sale } } fn unix_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() } pub fn readiness_check(id: &str, passed: bool, evidence: &str) -> ReadinessCheck { ReadinessCheck { id: id.to_string(), - status: if passed { - ReadinessStatus::Pass - } else { - ReadinessStatus::Fail - }, + status: if passed { ReadinessStatus::Pass } else { ReadinessStatus::Fail }, evidence: evidence.to_string(), } } @@ -1397,54 +968,25 @@ pub fn export_dnsbl_zone(origin: &str, entries: &[DnsblEntry]) -> String { let mut out = format!("$ORIGIN {}.\n$TTL 300\n", sanitize_zone_origin(origin)); for entry in entries { if let IpAddr::V4(address) = entry.address { - // The response code is emitted as a bare, unquoted A-record token, so - // it must be a valid IPv4 loopback literal (RFC 5782: DNSBL answers - // live in 127.0.0.0/8). `validate_dnsbl` enforces this at the - // create/import boundary, but the persisted-state deserializer is an - // untrusted surface that is not re-validated on load, so a state file - // can carry a code outside 127/8, an IPv6 literal, or a zone-injection - // string (e.g. a newline plus a forged `IN TXT` line). Re-enforce the - // invariant here and re-render the canonical form so no non-loopback, - // non-IPv4, or attacker-controlled bytes survive into the zone. let code = match IpAddr::from_str(&entry.code) { Ok(IpAddr::V4(code)) if code.octets()[0] == 127 => code, _ => continue, }; let name = reverse_ipv4_for_dnsbl(address.octets()); out.push_str(&format!("{} IN A {}\n", name, code)); - out.push_str(&format!( - "{} IN TXT \"{}\"\n", - name, - escape_txt(&format!("{} source={}", entry.reason, entry.source)) - )); + out.push_str(&format!("{} IN TXT \"{}\"\n", name, escape_txt(&format!("{} source={}", entry.reason, entry.source)))); } } out } -/// Sanitize a DNS zone origin so operator/threat-feed input can never break out -/// of the generated zone file. A legitimate origin is a domain name, so only -/// letters, digits, `-`, `_`, and `.` are kept; every other byte (newline, -/// quote, space, control char) is dropped. Leading/trailing dots are trimmed -/// because the caller re-appends the root dot. Empty input falls back to the -/// RFC 6761 reserved `.invalid` TLD, which is guaranteed non-resolvable. fn sanitize_zone_origin(origin: &str) -> String { - let filtered: String = origin - .trim() - .chars() - .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) - .collect(); + let filtered: String = origin.trim().chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(); let trimmed = filtered.trim_matches('.'); - if trimmed.is_empty() { - "dnsbl.invalid".to_string() - } else { - trimmed.to_string() - } + if trimmed.is_empty() { "dnsbl.invalid".to_string() } else { trimmed.to_string() } } -pub fn reverse_ipv4_for_dnsbl(octets: [u8; 4]) -> String { - format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0]) -} +pub fn reverse_ipv4_for_dnsbl(octets: [u8; 4]) -> String { format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0]) } fn escape_txt(value: &str) -> String { let mut out = String::with_capacity(value.len()); @@ -1452,15 +994,9 @@ fn escape_txt(value: &str) -> String { match ch { '\\' => out.push_str("\\\\"), '"' => out.push_str("\\\""), - // Control characters (notably raw newlines) would otherwise terminate - // the single-line TXT record and let a crafted reason/source inject - // subsequent zone lines. Emit them as BIND decimal escapes (`\DDD`) - // so the payload stays on one fully-quoted line. c if c.is_control() => { let mut buf = [0u8; 4]; - for &b in c.encode_utf8(&mut buf).as_bytes() { - out.push_str(&format!("\\{b:03}")); - } + for &b in c.encode_utf8(&mut buf).as_bytes() { out.push_str(&format!("\\{b:03}")); } } c => out.push(c), } @@ -1472,90 +1008,48 @@ fn escape_txt(value: &str) -> String { mod tests { use super::*; + #[test] + fn predecessor_state_defaults_dnsbl_ownership() { + let predecessor = r#"{ + "routes":[],"threats":[],"operator_threat_keys":[],"dnsbl":[], + "events":[],"next_event_id":1, + "threat_feed_ownership":[{"feed_id":"feed-a","threat_keys":[]}] + }"#; + let loaded: AppData = serde_json::from_str(predecessor).unwrap(); + assert!(loaded.operator_dnsbl_keys.is_empty()); + assert_eq!(loaded.threat_feed_ownership.len(), 1); + assert!(loaded.threat_feed_ownership[0].dnsbl_keys.is_empty()); + } + #[test] fn score_request_matches_client_ip_threat_indicators() { - let threats = vec![ThreatIndicator { - value: "203.0.113.50".to_string(), - indicator_type: "client_ip".to_string(), - severity: Severity::High, - source: "engine:coraza".to_string(), - ttl_seconds: 3600, - }]; - let hit = score_request( - "/index", - None, - "", - Some("203.0.113.50".parse().unwrap()), - &threats, - &[], - ); + let threats = vec![ThreatIndicator { value: "203.0.113.50".to_string(), indicator_type: "client_ip".to_string(), severity: Severity::High, source: "engine:coraza".to_string(), ttl_seconds: 3600 }]; + let hit = score_request("/index", None, "", Some("203.0.113.50".parse().unwrap()), &threats, &[]); assert!(hit.score >= BLOCK_SCORE); assert!(hit.reason.contains("client_ip")); - let miss = score_request( - "/index", - None, - "", - Some("198.51.100.1".parse().unwrap()), - &threats, - &[], - ); + let miss = score_request("/index", None, "", Some("198.51.100.1".parse().unwrap()), &threats, &[]); assert_eq!(miss.score, 0); } #[test] fn score_request_never_content_matches_cve_indicators() { - // A CVE indicator (e.g. from a CISA KEV import) is vulnerability - // metadata, not a request-content signature: a security-tooling - // request can legitimately carry the literal CVE string, and that - // must never contribute to the block score. - let threats = vec![ThreatIndicator { - value: "CVE-2021-44228".to_string(), - indicator_type: "cve".to_string(), - severity: Severity::Critical, - source: "feed:cisa-kev".to_string(), - ttl_seconds: 86_400, - }]; - let hit = score_request( - "/api/cve/CVE-2021-44228", - None, - "looking up CVE-2021-44228 details", - None, - &threats, - &[], - ); + let threats = vec![ThreatIndicator { value: "CVE-2021-44228".to_string(), indicator_type: "cve".to_string(), severity: Severity::Critical, source: "feed:cisa-kev".to_string(), ttl_seconds: 86_400 }]; + let hit = score_request("/api/cve/CVE-2021-44228", None, "looking up CVE-2021-44228 details", None, &threats, &[]); assert_eq!(hit.score, 0); assert_eq!(hit.reason, "no matching indicator"); } #[test] fn score_request_saturates_instead_of_overflowing_on_many_matches() { - // Regression: `score` is a u16 accumulator. With enough matching - // indicators (each Critical = 100), a plain `+=` overflows u16 (>65535): - // a debug/overflow-checked build panics -- violating the WAF invariant - // that scoring never panics on arbitrary input -- and a release build - // wraps the score down to a tiny value, silently letting a maximally - // malicious request slip under the block threshold. Saturating - // arithmetic must clamp the score at u16::MAX so the request still - // scores as blockable. 700 * 100 = 70000 exceeds u16::MAX (65535). - let threats: Vec = (0..700) - .map(|i| ThreatIndicator { - value: "attack".to_string(), - indicator_type: "keyword".to_string(), - severity: Severity::Critical, - source: format!("feed-{i}"), - ttl_seconds: 300, - }) - .collect(); - + let threats: Vec = (0..700).map(|i| ThreatIndicator { + value: "attack".to_string(), indicator_type: "keyword".to_string(), severity: Severity::Critical, + source: format!("feed-{i}"), ttl_seconds: 300, + }).collect(); let scored = score_request("/attack", None, "attack", None, &threats, &[]); - assert_eq!(scored.score, u16::MAX); assert!(scored.score >= BLOCK_SCORE); } - /// Assert every double quote inside a TXT payload is backslash-escaped, i.e. - /// preceded by an odd run of backslashes. Mirrors the fuzz/proptest invariant - /// so regressions in zone escaping fail as a plain unit test too. fn assert_txt_quotes_escaped(zone: &str) { for line in zone.lines().filter(|l| l.contains(" IN TXT ")) { let start = line.find('"').expect("TXT record has an opening quote"); @@ -1565,14 +1059,8 @@ mod tests { if b == b'"' { let mut backslashes = 0usize; let mut j = idx; - while j > 0 && payload[j - 1] == b'\\' { - backslashes += 1; - j -= 1; - } - assert!( - backslashes % 2 == 1, - "unescaped quote in TXT payload: {line:?}" - ); + while j > 0 && payload[j - 1] == b'\\' { backslashes += 1; j -= 1; } + assert!(backslashes % 2 == 1, "unescaped quote in TXT payload: {line:?}"); } } } @@ -1580,41 +1068,20 @@ mod tests { #[test] fn export_dnsbl_zone_resists_origin_zone_injection() { - // Reproduces the fuzz crash: a crafted origin carrying a newline plus a - // forged `IN TXT` line with bare double quotes must not break out of the - // generated zone. let zone = export_dnsbl_zone( "dn\nner\"\"\"\"\"\"\"\" IN TXT \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\";eed", - &[DnsblEntry { - address: "192.0.2.10".parse().unwrap(), - code: "127.0.0.2".to_string(), - reason: "scanner".to_string(), - source: "unit".to_string(), - ttl_seconds: 300, - prefix_len: None, - }], + &[DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "127.0.0.2".to_string(), reason: "scanner".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }], ); assert!(zone.starts_with("$ORIGIN ")); - // The origin is sanitized down to DNS-safe characters on a single line. assert_eq!(zone.lines().next().unwrap(), "$ORIGIN dnnerINTXTeed."); assert_txt_quotes_escaped(&zone); } #[test] fn export_dnsbl_zone_escapes_quotes_and_backslashes_in_reason() { - // Reason carrying both a backslash and a double quote must be escaped so - // the quote stays inside the payload (`\"`) and the backslash is doubled - // (`\\`); this also exercises the escaped-quote path of the checker. let zone = export_dnsbl_zone( "dnsbl.example", - &[DnsblEntry { - address: "192.0.2.10".parse().unwrap(), - code: "127.0.0.2".to_string(), - reason: "back\\slash and \"quote\"".to_string(), - source: "unit".to_string(), - ttl_seconds: 300, - prefix_len: None, - }], + &[DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "127.0.0.2".to_string(), reason: "back\\slash and \"quote\"".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }], ); assert!(zone.contains("10.2.0.192 IN TXT \"back\\\\slash and \\\"quote\\\" source=unit\"")); assert_txt_quotes_escaped(&zone); @@ -1622,30 +1089,13 @@ mod tests { #[test] fn export_dnsbl_zone_rejects_non_ip_code_injection() { - // A `code` that is not a valid IP literal would become a bare A-record - // token; a newline-bearing value must be dropped, never rendered. let zone = export_dnsbl_zone( "dnsbl.example", &[ - DnsblEntry { - address: "192.0.2.10".parse().unwrap(), - code: "127.0.0.2\n10.2.0.192 IN TXT \"pwned".to_string(), - reason: "scanner".to_string(), - source: "unit".to_string(), - ttl_seconds: 300, - prefix_len: None, - }, - DnsblEntry { - address: "192.0.2.20".parse().unwrap(), - code: "127.0.0.9".to_string(), - reason: "ok".to_string(), - source: "unit".to_string(), - ttl_seconds: 300, - prefix_len: None, - }, + DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "127.0.0.2\n10.2.0.192 IN TXT \"pwned".to_string(), reason: "scanner".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }, + DnsblEntry { address: "192.0.2.20".parse().unwrap(), code: "127.0.0.9".to_string(), reason: "ok".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }, ], ); - // The malformed-code entry is skipped entirely; the valid one renders. assert!(!zone.contains("pwned")); assert!(zone.contains("20.2.0.192 IN A 127.0.0.9")); assert_txt_quotes_escaped(&zone); @@ -1653,90 +1103,33 @@ mod tests { #[test] fn export_dnsbl_zone_omits_non_loopback_response_codes() { - // `validate_dnsbl` gates the create/import path to `127.0.0.0/8`, but the - // persisted-state deserializer (a documented untrusted-input surface) is - // NOT re-validated on load, so a state file can carry a DNSBL entry whose - // `code` is a valid IP outside 127/8 — or an IPv6 literal. The zone export - // is the output boundary that publishes each code as a bare A-record - // token, so it must re-enforce the "response code in 127.0.0.0/8" - // invariant itself: a non-loopback IPv4 answer breaks RFC 5782 semantics - // for every DNSBL consumer, and an IPv6 literal yields a syntactically - // invalid A record that fails the whole authoritative zone load. let zone = export_dnsbl_zone( "dnsbl.example", &[ - DnsblEntry { - address: "192.0.2.10".parse().unwrap(), - // Valid IPv4, but NOT in 127.0.0.0/8. - code: "8.8.8.8".to_string(), - reason: "spoofed".to_string(), - source: "state-file".to_string(), - ttl_seconds: 300, - prefix_len: None, - }, - DnsblEntry { - address: "192.0.2.20".parse().unwrap(), - // IPv6 literal — never a legal A-record response code. - code: "::1".to_string(), - reason: "spoofed6".to_string(), - source: "state-file".to_string(), - ttl_seconds: 300, - prefix_len: None, - }, - DnsblEntry { - address: "192.0.2.30".parse().unwrap(), - // Valid loopback code — must still render. - code: "127.0.0.4".to_string(), - reason: "ok".to_string(), - source: "state-file".to_string(), - ttl_seconds: 300, - prefix_len: None, - }, + DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "8.8.8.8".to_string(), reason: "spoofed".to_string(), source: "state-file".to_string(), ttl_seconds: 300, prefix_len: None }, + DnsblEntry { address: "192.0.2.20".parse().unwrap(), code: "::1".to_string(), reason: "spoofed6".to_string(), source: "state-file".to_string(), ttl_seconds: 300, prefix_len: None }, + DnsblEntry { address: "192.0.2.30".parse().unwrap(), code: "127.0.0.4".to_string(), reason: "ok".to_string(), source: "state-file".to_string(), ttl_seconds: 300, prefix_len: None }, ], ); - // No non-loopback or non-IPv4 answer may escape into the published zone. - assert!( - !zone.contains("IN A 8.8.8.8"), - "non-127/8 A record leaked into zone: {zone}" - ); - assert!( - !zone.contains("IN A ::1"), - "IPv6 A record leaked into zone: {zone}" - ); - // Every emitted A record's response code is an IPv4 loopback address. + assert!(!zone.contains("IN A 8.8.8.8"), "non-127/8 A record leaked into zone: {zone}"); + assert!(!zone.contains("IN A ::1"), "IPv6 A record leaked into zone: {zone}"); for line in zone.lines().filter(|l| l.contains(" IN A ")) { let code = line.rsplit(" IN A ").next().unwrap().trim(); match IpAddr::from_str(code).expect("A-record code is an IP literal") { - IpAddr::V4(v4) => assert_eq!( - v4.octets()[0], - 127, - "non-loopback DNSBL response code published: {code}" - ), + IpAddr::V4(v4) => assert_eq!(v4.octets()[0], 127, "non-loopback DNSBL response code published: {code}"), IpAddr::V6(_) => panic!("IPv6 DNSBL response code published: {code}"), } } - // The legitimate loopback entry is unaffected. assert!(zone.contains("30.2.0.192 IN A 127.0.0.4")); assert_txt_quotes_escaped(&zone); } #[test] fn export_dnsbl_zone_escapes_control_chars_in_reason() { - // A raw newline in reason/source must be neutralized so the TXT record - // stays on one line and cannot inject subsequent zone entries. let zone = export_dnsbl_zone( "dnsbl.example", - &[DnsblEntry { - address: "192.0.2.10".parse().unwrap(), - code: "127.0.0.2".to_string(), - reason: "line1\n10.2.0.192 IN TXT \"break".to_string(), - source: "unit".to_string(), - ttl_seconds: 300, - prefix_len: None, - }], + &[DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "127.0.0.2".to_string(), reason: "line1\n10.2.0.192 IN TXT \"break".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }], ); - // No raw newline survives inside the TXT payload: the whole record, - // including the injected `IN TXT` text, stays on a single line. assert_eq!(zone.lines().filter(|l| l.contains(" IN TXT ")).count(), 1); assert!(zone.contains("\\010")); assert_txt_quotes_escaped(&zone); @@ -1746,26 +1139,18 @@ mod tests { fn sanitize_zone_origin_falls_back_when_empty() { assert_eq!(sanitize_zone_origin("\"\n\t \""), "dnsbl.invalid"); assert_eq!(sanitize_zone_origin("dnsbl.example."), "dnsbl.example"); - assert_eq!( - sanitize_zone_origin("dnsbl_feed.example-1"), - "dnsbl_feed.example-1" - ); + assert_eq!(sanitize_zone_origin("dnsbl_feed.example-1"), "dnsbl_feed.example-1"); } #[test] fn anomaly_signal_flags_metacharacters_and_entropy() { - // Metacharacter density on a short payload (entropy check length-gated out). let (score, reason) = anomaly_signal("ac'd\"e(f)g;h|i&j").unwrap(); assert_eq!(score, 15); assert!(reason.contains("metacharacters")); - - // High-entropy encoded blob (40+ bytes, no metacharacters). let blob = "aGVsbG8Xd29ybGQ0Zm9vYmFyMTIzNDU2Nzg5MDBhYmNkZWZn"; let (score, reason) = anomaly_signal(blob).unwrap(); assert_eq!(score, 10); assert!(reason.contains("entropy")); - - // Long but low-entropy (repeated byte) and ordinary short text: not flagged. assert!(anomaly_signal(&"a".repeat(60)).is_none()); assert!(anomaly_signal("/account/profile?tab=settings").is_none()); } @@ -1779,30 +1164,8 @@ mod tests { #[test] fn records_audit_logs_with_monotonic_ids() { let mut data = AppData::seeded(); - - let first = record_audit_log( - &mut data, - NewAuditLogEntry { - timestamp_unix: 10, - actor: "operator@example.com".to_string(), - action: "upsert_route".to_string(), - resource: "route".to_string(), - resource_id: "edge".to_string(), - outcome: "success".to_string(), - }, - ); - let second = record_audit_log( - &mut data, - NewAuditLogEntry { - timestamp_unix: 11, - actor: "operator@example.com".to_string(), - action: "update_license".to_string(), - resource: "commercial_license".to_string(), - resource_id: "cwlab-enterprise".to_string(), - outcome: "success".to_string(), - }, - ); - + let first = record_audit_log(&mut data, NewAuditLogEntry { timestamp_unix: 10, actor: "operator@example.com".to_string(), action: "upsert_route".to_string(), resource: "route".to_string(), resource_id: "edge".to_string(), outcome: "success".to_string() }); + let second = record_audit_log(&mut data, NewAuditLogEntry { timestamp_unix: 11, actor: "operator@example.com".to_string(), action: "update_license".to_string(), resource: "commercial_license".to_string(), resource_id: "cwlab-enterprise".to_string(), outcome: "success".to_string() }); assert_eq!(first.id, 1); assert_eq!(second.id, 2); assert_eq!(data.audit_logs.len(), 2); From a81f3a1ed3f1b7190fd6aec0784bd647a9ec4462 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:47:18 +0900 Subject: [PATCH 17/34] revert: restore minimal Wardnet feed core baseline --- crates/waf-ids-core/src/lib.rs | 983 +++++++++++++++++++++++++++------ 1 file changed, 810 insertions(+), 173 deletions(-) diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index f620d6c..f9673e0 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -14,8 +14,6 @@ pub struct AppData { #[serde(default)] pub operator_threat_keys: Vec, pub dnsbl: Vec, - #[serde(default)] - pub operator_dnsbl_keys: Vec, pub events: Vec, pub next_event_id: u64, #[serde(default)] @@ -57,7 +55,6 @@ impl AppData { ttl_seconds: 300, prefix_len: None, }], - operator_dnsbl_keys: Vec::new(), events: Vec::new(), next_event_id: 1, audit_logs: Vec::new(), @@ -141,13 +138,6 @@ pub struct DnsblEntry { pub prefix_len: Option, } -/// Stable DNSBL ownership identity. It mirrors [`upsert_dnsbl`], which stores -/// one effective DNSBL row per IP address regardless of producer payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub struct DnsblEntryKey { - pub address: IpAddr, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CommercialProfile { pub tenant_id: String, @@ -199,8 +189,6 @@ pub struct ThreatFeedStatus { pub struct ThreatFeedOwnership { pub feed_id: String, pub threat_keys: Vec, - #[serde(default)] - pub dnsbl_keys: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -503,6 +491,9 @@ pub fn validate_threat_feed_import(feed: &ThreatFeedImport) -> Result<(), &'stat if feed.ttl_seconds == 0 { return Err("threat feed ttl_seconds must be greater than 0"); } + if feed.threats.is_empty() && feed.dnsbl.is_empty() { + return Err("threat feed must include at least one threat or DNSBL entry"); + } for threat in &feed.threats { validate_threat(threat)?; } @@ -569,37 +560,21 @@ pub fn threat_indicator_key(indicator: &ThreatIndicator) -> ThreatIndicatorKey { } } -/// Returns the durable ownership identity used by DNSBL snapshot reconciliation. -pub fn dnsbl_entry_key(entry: &DnsblEntry) -> DnsblEntryKey { - DnsblEntryKey { - address: entry.address, - } -} - -/// Replaces one feed's complete threat and DNSBL claim sets and returns the -/// persisted predecessor snapshot so callers can retire claims that disappeared. pub fn replace_threat_feed_ownership( ownership: &mut Vec, feed_id: String, threat_keys: Vec, - dnsbl_keys: Vec, -) -> ThreatFeedOwnership { +) -> Vec { if let Some(existing) = ownership.iter_mut().find(|item| item.feed_id == feed_id) { - let previous = existing.clone(); + let previous = existing.threat_keys.clone(); existing.threat_keys = threat_keys; - existing.dnsbl_keys = dnsbl_keys; previous } else { ownership.push(ThreatFeedOwnership { - feed_id: feed_id.clone(), + feed_id, threat_keys, - dnsbl_keys, }); - ThreatFeedOwnership { - feed_id, - threat_keys: Vec::new(), - dnsbl_keys: Vec::new(), - } + Vec::new() } } @@ -642,32 +617,168 @@ pub struct BuiltinSignature { /// false positives; operator [`ThreatIndicator`]s cover site-specific payloads. pub fn builtin_signatures() -> &'static [BuiltinSignature] { const SIGS: &[BuiltinSignature] = &[ - BuiltinSignature { id: "sqli-union-select", class: "sqli", pattern: "union select", severity: Severity::High }, - BuiltinSignature { id: "sqli-or-tautology", class: "sqli", pattern: "or 1=1", severity: Severity::High }, - BuiltinSignature { id: "sqli-quoted-tautology", class: "sqli", pattern: "' or '", severity: Severity::High }, - BuiltinSignature { id: "sqli-comment", class: "sqli", pattern: "'--", severity: Severity::Medium }, - BuiltinSignature { id: "sqli-sleep", class: "sqli", pattern: "sleep(", severity: Severity::High }, - BuiltinSignature { id: "sqli-benchmark", class: "sqli", pattern: "benchmark(", severity: Severity::High }, - BuiltinSignature { id: "sqli-waitfor", class: "sqli", pattern: "waitfor delay", severity: Severity::High }, - BuiltinSignature { id: "sqli-info-schema", class: "sqli", pattern: "information_schema", severity: Severity::Medium }, - BuiltinSignature { id: "xss-script-tag", class: "xss", pattern: " Vec { pub fn anomaly_signal(haystack: &str) -> Option<(u16, String)> { let mut score = 0u16; let mut reasons = Vec::new(); + + // Signal 1: shell/markup metacharacter density. const META: &str = "<>'\"();|&$`"; let suspicious = haystack.chars().filter(|c| META.contains(*c)).count(); let ratio = suspicious as f64 / haystack.chars().count().max(1) as f64; if suspicious >= 6 && ratio >= 0.08 { score += 15; - reasons.push(format!("{suspicious} metacharacters ({:.0}% density)", ratio * 100.0)); + reasons.push(format!( + "{suspicious} metacharacters ({:.0}% density)", + ratio * 100.0 + )); } + + // Signal 2: high Shannon entropy over a non-trivial payload — a marker of + // encoded/obfuscated content (base64 blobs, packed exploit strings) that + // signature matching misses. Length-gated so short requests never trip it. if haystack.len() >= 40 { let entropy = shannon_entropy(haystack.as_bytes()); if entropy >= 4.5 { @@ -717,17 +837,29 @@ pub fn anomaly_signal(haystack: &str) -> Option<(u16, String)> { reasons.push(format!("high entropy {entropy:.1} bits/byte")); } } - if score == 0 { None } else { Some((score, format!("anomaly heuristic: {}", reasons.join("; ")))) } + + if score == 0 { + None + } else { + Some((score, format!("anomaly heuristic: {}", reasons.join("; ")))) + } } +/// Shannon entropy in bits per byte of a non-empty byte slice. fn shannon_entropy(bytes: &[u8]) -> f64 { let mut counts = [0u32; 256]; - for &byte in bytes { counts[byte as usize] += 1; } + for &byte in bytes { + counts[byte as usize] += 1; + } let total = bytes.len() as f64; - counts.iter().filter(|&&count| count > 0).map(|&count| { - let p = count as f64 / total; - -p * p.log2() - }).sum() + counts + .iter() + .filter(|&&count| count > 0) + .map(|&count| { + let p = count as f64 / total; + -p * p.log2() + }) + .sum() } pub fn score_request( @@ -738,43 +870,70 @@ pub fn score_request( threats: &[ThreatIndicator], dnsbl: &[DnsblEntry], ) -> ScoredRequest { - let decoded_query = query.map(|value| percent_decode_str(value).decode_utf8_lossy()).unwrap_or_default(); + let decoded_query = query + .map(|value| percent_decode_str(value).decode_utf8_lossy()) + .unwrap_or_default(); let haystack = format!("{}?{} {}", path, decoded_query, body).to_lowercase(); let mut score: u16 = 0; let mut reasons = Vec::new(); + + // Built-in OWASP-shape signatures (no operator configuration required). for sig in builtin_signatures() { if haystack.contains(sig.pattern) { score = score.saturating_add(severity_score(&sig.severity)); reasons.push(format!("builtin {} rule {}", sig.class, sig.id)); } } + + // Operator-configured threat indicators (and engine-fed IP/path hits). for indicator in threats { let kind = indicator.indicator_type.to_ascii_lowercase(); let matched = if matches!(kind.as_str(), "ip" | "client_ip" | "source_ip" | "src_ip") { client_ip.is_some_and(|ip| indicator.value.parse::().ok() == Some(ip)) } else if kind == "cve" { + // A CVE identifier is vulnerability-catalog metadata (e.g. from a + // CISA KEV import), not a request-content observable: it can + // legitimately appear in a vulnerability-management or security + // tool's own traffic (`/api/cve/CVE-2021-44228`), so it must never + // drive content-substring scoring. It stays visible via the + // threat-indicator, feed-freshness, and buyer-evidence APIs. false } else { haystack.contains(&indicator.value.to_lowercase()) }; if matched { score = score.saturating_add(severity_score(&indicator.severity)); - reasons.push(format!("{} indicator from {}", indicator.indicator_type, indicator.source)); + reasons.push(format!( + "{} indicator from {}", + indicator.indicator_type, indicator.source + )); } } + + // DNSBL client reputation. if let Some(ip) = client_ip && let Some(entry) = dnsbl.iter().find(|entry| dnsbl_matches(entry, ip)) { score = score.saturating_add(100); - reasons.push(format!("DNSBL match {} from {}", entry.reason, entry.source)); + reasons.push(format!( + "DNSBL match {} from {}", + entry.reason, entry.source + )); } + + // Behavioral anomaly heuristic (first-tier AI SOC signal). if let Some((anomaly, reason)) = anomaly_signal(&haystack) { score = score.saturating_add(anomaly); reasons.push(reason); } + ScoredRequest { score, - reason: if reasons.is_empty() { "no matching indicator".to_string() } else { reasons.join("; ") }, + reason: if reasons.is_empty() { + "no matching indicator".to_string() + } else { + reasons.join("; ") + }, } } @@ -787,8 +946,22 @@ pub fn severity_score(severity: &Severity) -> u16 { } } -pub fn rate_limit_step(now_unix: u64, window_start: u64, count: u32, limit: u32, window_secs: u64) -> (bool, u64, u32) { - if limit == 0 { return (true, window_start, count); } +/// Fixed-window rate-limit arithmetic for one client key. Given the current +/// window state `(window_start, count)`, returns `(allowed, new_window_start, +/// new_count)`. `limit == 0` disables limiting (always allowed). +/// +/// ponytail: fixed window — permits up to ~2x `limit` across a boundary; swap +/// for a sliding window if that burst matters. +pub fn rate_limit_step( + now_unix: u64, + window_start: u64, + count: u32, + limit: u32, + window_secs: u64, +) -> (bool, u64, u32) { + if limit == 0 { + return (true, window_start, count); + } if now_unix.saturating_sub(window_start) >= window_secs.max(1) { (true, now_unix, 1) } else if count < limit { @@ -805,18 +978,31 @@ pub fn enforce_event_limit(data: &mut AppData, limit: usize) { } } -pub fn threat_feed_freshness_snapshot(feeds: &[ThreatFeedStatus], now_unix: u64) -> Vec { - feeds.iter().map(|feed| { - let expires_at_unix = feed.last_updated_unix.saturating_add(feed.ttl_seconds); - ThreatFeedFreshness { - feed_id: feed.feed_id.clone(), source: feed.source.clone(), last_updated_unix: feed.last_updated_unix, - threat_count: feed.threat_count, dnsbl_count: feed.dnsbl_count, ttl_seconds: feed.ttl_seconds, - expires_at_unix, stale: expires_at_unix <= now_unix, - } - }).collect() +pub fn threat_feed_freshness_snapshot( + feeds: &[ThreatFeedStatus], + now_unix: u64, +) -> Vec { + feeds + .iter() + .map(|feed| { + let expires_at_unix = feed.last_updated_unix.saturating_add(feed.ttl_seconds); + ThreatFeedFreshness { + feed_id: feed.feed_id.clone(), + source: feed.source.clone(), + last_updated_unix: feed.last_updated_unix, + threat_count: feed.threat_count, + dnsbl_count: feed.dnsbl_count, + ttl_seconds: feed.ttl_seconds, + expires_at_unix, + stale: expires_at_unix <= now_unix, + } + }) + .collect() } -pub fn kpi_snapshot(data: &AppData) -> SocKpiSnapshot { kpi_snapshot_at(data, unix_now()) } +pub fn kpi_snapshot(data: &AppData) -> SocKpiSnapshot { + kpi_snapshot_at(data, unix_now()) +} pub fn kpi_snapshot_at(data: &AppData, now_unix: u64) -> SocKpiSnapshot { let feed_freshness = threat_feed_freshness_snapshot(&data.threat_feeds, now_unix); @@ -828,62 +1014,169 @@ pub fn kpi_snapshot_at(data: &AppData, now_unix: u64) -> SocKpiSnapshot { fresh_threat_feed_count: feed_freshness.iter().filter(|feed| !feed.stale).count(), stale_threat_feed_count: feed_freshness.iter().filter(|feed| feed.stale).count(), event_count: data.events.len(), - blocked_event_count: data.events.iter().filter(|event| event.action == "blocked").count(), - monitor_event_count: data.events.iter().filter(|event| event.action == "monitored").count(), + blocked_event_count: data + .events + .iter() + .filter(|event| event.action == "blocked") + .count(), + monitor_event_count: data + .events + .iter() + .filter(|event| event.action == "monitored") + .count(), audit_log_count: data.audit_logs.len(), gateway_mode: "rust-first edge gateway program baseline".to_string(), } } +/// Renders a [`SocKpiSnapshot`] as Prometheus text exposition (0.0.4). Counters +/// are current-state gauges, so a `gauge` type is correct for scrapers. No +/// dependency — the format is a few lines of text. pub fn prometheus_exposition(kpi: &SocKpiSnapshot) -> String { let metrics: [(&str, &str, usize); 10] = [ - ("waf_ids_routes", "Configured gateway routes.", kpi.route_count), - ("waf_ids_threat_indicators", "Operator threat indicators loaded.", kpi.threat_indicator_count), - ("waf_ids_dnsbl_entries", "DNSBL reputation entries.", kpi.dnsbl_entry_count), - ("waf_ids_threat_feeds", "Imported threat feeds.", kpi.threat_feed_count), - ("waf_ids_threat_feeds_fresh", "Threat feeds within their TTL.", kpi.fresh_threat_feed_count), - ("waf_ids_threat_feeds_stale", "Threat feeds past their TTL.", kpi.stale_threat_feed_count), - ("waf_ids_security_events", "Total recorded security events.", kpi.event_count), - ("waf_ids_security_events_blocked", "Security events with a blocked action.", kpi.blocked_event_count), - ("waf_ids_security_events_monitored", "Security events with a monitored action.", kpi.monitor_event_count), - ("waf_ids_audit_log_entries", "Recorded management audit-log entries.", kpi.audit_log_count), + ( + "waf_ids_routes", + "Configured gateway routes.", + kpi.route_count, + ), + ( + "waf_ids_threat_indicators", + "Operator threat indicators loaded.", + kpi.threat_indicator_count, + ), + ( + "waf_ids_dnsbl_entries", + "DNSBL reputation entries.", + kpi.dnsbl_entry_count, + ), + ( + "waf_ids_threat_feeds", + "Imported threat feeds.", + kpi.threat_feed_count, + ), + ( + "waf_ids_threat_feeds_fresh", + "Threat feeds within their TTL.", + kpi.fresh_threat_feed_count, + ), + ( + "waf_ids_threat_feeds_stale", + "Threat feeds past their TTL.", + kpi.stale_threat_feed_count, + ), + ( + "waf_ids_security_events", + "Total recorded security events.", + kpi.event_count, + ), + ( + "waf_ids_security_events_blocked", + "Security events with a blocked action.", + kpi.blocked_event_count, + ), + ( + "waf_ids_security_events_monitored", + "Security events with a monitored action.", + kpi.monitor_event_count, + ), + ( + "waf_ids_audit_log_entries", + "Recorded management audit-log entries.", + kpi.audit_log_count, + ), ]; let mut out = String::new(); for (name, help, value) in metrics { - out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} gauge\n{name} {value}\n")); + out.push_str(&format!( + "# HELP {name} {help}\n# TYPE {name} gauge\n{name} {value}\n" + )); } out } -pub fn commercial_readiness_snapshot(data: &AppData) -> CommercialReadiness { commercial_readiness_snapshot_at(data, unix_now()) } +pub fn commercial_readiness_snapshot(data: &AppData) -> CommercialReadiness { + commercial_readiness_snapshot_at(data, unix_now()) +} pub fn commercial_readiness_snapshot_at(data: &AppData, now_unix: u64) -> CommercialReadiness { - let license_ready = matches!(data.commercial.license_status, LicenseStatus::Active | LicenseStatus::Evaluation) - && data.commercial.license_id.as_deref().is_some_and(|value| !value.trim().is_empty()) - && data.commercial.licensee.as_deref().is_some_and(|value| !value.trim().is_empty()); - let commercial_value_ready = data.commercial.annual_contract_value_krw.is_some_and(|value| value >= TARGET_SALE_VALUE_KRW); + let license_ready = matches!( + data.commercial.license_status, + LicenseStatus::Active | LicenseStatus::Evaluation + ) && data + .commercial + .license_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + && data + .commercial + .licensee + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + let commercial_value_ready = data + .commercial + .annual_contract_value_krw + .is_some_and(|value| value >= TARGET_SALE_VALUE_KRW); let threat_feed_ready = threat_feed_freshness_snapshot(&data.threat_feeds, now_unix) - .iter().any(|feed| !feed.stale && (feed.threat_count > 0 || feed.dnsbl_count > 0)); + .iter() + .any(|feed| !feed.stale && (feed.threat_count > 0 || feed.dnsbl_count > 0)); let route_ready = data.routes.iter().any(|route| route.enabled); let dnsbl_ready = !data.dnsbl.is_empty(); let support_evidence_ready = !data.events.is_empty(); + let checks = vec![ - readiness_check("license", license_ready, "active/evaluation tenant license metadata is present"), - readiness_check("contract_value", commercial_value_ready, "annual contract value meets the 2B KRW sale target"), - readiness_check("threat_feed_updates", threat_feed_ready, "at least one imported threat feed is fresh within its TTL"), - readiness_check("gateway_enforcement", route_ready, "at least one enabled gateway route is configured"), - readiness_check("dnsbl_publication", dnsbl_ready, "DNSBL entries are available for zone export"), - readiness_check("support_evidence", support_evidence_ready, "security event evidence is available for a support bundle"), + readiness_check( + "license", + license_ready, + "active/evaluation tenant license metadata is present", + ), + readiness_check( + "contract_value", + commercial_value_ready, + "annual contract value meets the 2B KRW sale target", + ), + readiness_check( + "threat_feed_updates", + threat_feed_ready, + "at least one imported threat feed is fresh within its TTL", + ), + readiness_check( + "gateway_enforcement", + route_ready, + "at least one enabled gateway route is configured", + ), + readiness_check( + "dnsbl_publication", + dnsbl_ready, + "DNSBL entries are available for zone export", + ), + readiness_check( + "support_evidence", + support_evidence_ready, + "security event evidence is available for a support bundle", + ), ]; - let blockers: Vec = checks.iter().filter(|check| check.status == ReadinessStatus::Fail).map(|check| check.id.clone()).collect(); + let blockers: Vec = checks + .iter() + .filter(|check| check.status == ReadinessStatus::Fail) + .map(|check| check.id.clone()) + .collect(); let ready_for_enterprise_sale = blockers.is_empty(); + CommercialReadiness { target_sale_value_krw: TARGET_SALE_VALUE_KRW, ready_for_enterprise_sale, - readiness_level: if ready_for_enterprise_sale { "sale_ready".to_string() } else { "implementation_required".to_string() }, + readiness_level: if ready_for_enterprise_sale { + "sale_ready".to_string() + } else { + "implementation_required".to_string() + }, blockers, checks, - deployment_assets: vec!["Dockerfile".to_string(), "deploy/docker-compose.yml".to_string(), "deploy/kubernetes/waf-ids-ai-soc.yaml".to_string()], + deployment_assets: vec![ + "Dockerfile".to_string(), + "deploy/docker-compose.yml".to_string(), + "deploy/kubernetes/waf-ids-ai-soc.yaml".to_string(), + ], buyer_evidence: vec![ "docs/commercial/20b-krw-sale-readiness.md".to_string(), "docs/commercial/buyer-due-diligence.md".to_string(), @@ -893,11 +1186,14 @@ pub fn commercial_readiness_snapshot_at(data: &AppData, now_unix: u64) -> Commer } } -pub fn buyer_evidence_manifest(data: &AppData) -> BuyerEvidenceManifest { buyer_evidence_manifest_at(data, unix_now()) } +pub fn buyer_evidence_manifest(data: &AppData) -> BuyerEvidenceManifest { + buyer_evidence_manifest_at(data, unix_now()) +} pub fn buyer_evidence_manifest_at(data: &AppData, now_unix: u64) -> BuyerEvidenceManifest { let readiness = commercial_readiness_snapshot_at(data, now_unix); let kpis = kpi_snapshot_at(data, now_unix); + BuyerEvidenceManifest { generated_at_unix: now_unix, target_sale_value_krw: readiness.target_sale_value_krw, @@ -929,37 +1225,170 @@ pub fn buyer_evidence_manifest_at(data: &AppData, now_unix: u64) -> BuyerEvidenc fn buyer_evidence_endpoints() -> Vec { vec![ - buyer_evidence_endpoint("health", "GET", "/healthz", "application/json", "runtime health, persistence mode, DNSBL origin, and event retention limit", true), - buyer_evidence_endpoint("license", "GET", "/api/commercial/license", "application/json", "tenant, edition, license, support, node count, and contract metadata", true), - buyer_evidence_endpoint("readiness", "GET", "/api/commercial/readiness", "application/json", "2B KRW readiness checks and explicit blockers", true), - buyer_evidence_endpoint("evidence_manifest", "GET", "/api/commercial/evidence-manifest", "application/json", "buyer-verifiable evidence map for runtime APIs, docs, and deployment assets", true), - buyer_evidence_endpoint("feed_freshness", "GET", "/api/threat-feeds/freshness", "application/json", "fresh and stale threat-feed evidence from TTL and last update time", true), - buyer_evidence_endpoint("soc_event_export", "GET", "/api/events.ndjson", "application/x-ndjson", "one-security-event-per-line SOC/SIEM ingestion evidence", true), - buyer_evidence_endpoint("management_audit_logs", "GET", "/api/audit-logs", "application/json", "admin write history for buyer due-diligence without admin secrets", true), - buyer_evidence_endpoint("support_bundle", "GET", "/api/support-bundle", "application/json", "support and due-diligence handoff package without admin secrets", true), - buyer_evidence_endpoint("dnsbl_zone", "GET", "/dnsbl/zone", "text/plain", "RFC 5782-style DNSBL zone export for buyer lab DNS validation", true), - buyer_evidence_endpoint("suricata_eve_ingest", "POST", "/api/ids/suricata/eve", "application/json", "Suricata EVE JSON/NDJSON alert ingest into SOC security events (admin-auth)", false), - buyer_evidence_endpoint("coraza_audit_ingest", "POST", "/api/waf/coraza/audit", "application/json", "Coraza/OWASP CRS WAF audit JSON/NDJSON ingest into SOC security events (admin-auth)", false), - buyer_evidence_endpoint("stix_indicator_ingest", "POST", "/api/threat-intel/stix", "application/json", "STIX 2.x indicator/bundle ingest into threat indicators and DNSBL (admin-auth)", false), - buyer_evidence_endpoint("misp_event_ingest", "POST", "/api/threat-intel/misp", "application/json", "MISP Event/attribute JSON ingest into threat indicators and DNSBL (admin-auth)", false), - buyer_evidence_endpoint("taxii_collection_poll", "POST", "/api/threat-intel/taxii/poll", "application/json", "TAXII 2.1 collection objects poll into threat indicators and DNSBL (admin-auth)", false), - buyer_evidence_endpoint("opencti_observable_ingest", "POST", "/api/threat-intel/opencti", "application/json", "OpenCTI observable/indicator JSON ingest into threat indicators and DNSBL (admin-auth)", false), - buyer_evidence_endpoint("cisa_kev_ingest", "POST", "/api/threat-intel/cisa-kev", "application/json", "CISA Known Exploited Vulnerabilities catalog pull into CVE threat indicators (admin-auth)", false), + buyer_evidence_endpoint( + "health", + "GET", + "/healthz", + "application/json", + "runtime health, persistence mode, DNSBL origin, and event retention limit", + true, + ), + buyer_evidence_endpoint( + "license", + "GET", + "/api/commercial/license", + "application/json", + "tenant, edition, license, support, node count, and contract metadata", + true, + ), + buyer_evidence_endpoint( + "readiness", + "GET", + "/api/commercial/readiness", + "application/json", + "2B KRW readiness checks and explicit blockers", + true, + ), + buyer_evidence_endpoint( + "evidence_manifest", + "GET", + "/api/commercial/evidence-manifest", + "application/json", + "buyer-verifiable evidence map for runtime APIs, docs, and deployment assets", + true, + ), + buyer_evidence_endpoint( + "feed_freshness", + "GET", + "/api/threat-feeds/freshness", + "application/json", + "fresh and stale threat-feed evidence from TTL and last update time", + true, + ), + buyer_evidence_endpoint( + "soc_event_export", + "GET", + "/api/events.ndjson", + "application/x-ndjson", + "one-security-event-per-line SOC/SIEM ingestion evidence", + true, + ), + buyer_evidence_endpoint( + "management_audit_logs", + "GET", + "/api/audit-logs", + "application/json", + "admin write history for buyer due-diligence without admin secrets", + true, + ), + buyer_evidence_endpoint( + "support_bundle", + "GET", + "/api/support-bundle", + "application/json", + "support and due-diligence handoff package without admin secrets", + true, + ), + buyer_evidence_endpoint( + "dnsbl_zone", + "GET", + "/dnsbl/zone", + "text/plain", + "RFC 5782-style DNSBL zone export for buyer lab DNS validation", + true, + ), + buyer_evidence_endpoint( + "suricata_eve_ingest", + "POST", + "/api/ids/suricata/eve", + "application/json", + "Suricata EVE JSON/NDJSON alert ingest into SOC security events (admin-auth)", + false, + ), + buyer_evidence_endpoint( + "coraza_audit_ingest", + "POST", + "/api/waf/coraza/audit", + "application/json", + "Coraza/OWASP CRS WAF audit JSON/NDJSON ingest into SOC security events (admin-auth)", + false, + ), + buyer_evidence_endpoint( + "stix_indicator_ingest", + "POST", + "/api/threat-intel/stix", + "application/json", + "STIX 2.x indicator/bundle ingest into threat indicators and DNSBL (admin-auth)", + false, + ), + buyer_evidence_endpoint( + "misp_event_ingest", + "POST", + "/api/threat-intel/misp", + "application/json", + "MISP Event/attribute JSON ingest into threat indicators and DNSBL (admin-auth)", + false, + ), + buyer_evidence_endpoint( + "taxii_collection_poll", + "POST", + "/api/threat-intel/taxii/poll", + "application/json", + "TAXII 2.1 collection objects poll into threat indicators and DNSBL (admin-auth)", + false, + ), + buyer_evidence_endpoint( + "opencti_observable_ingest", + "POST", + "/api/threat-intel/opencti", + "application/json", + "OpenCTI observable/indicator JSON ingest into threat indicators and DNSBL (admin-auth)", + false, + ), + buyer_evidence_endpoint( + "cisa_kev_ingest", + "POST", + "/api/threat-intel/cisa-kev", + "application/json", + "CISA Known Exploited Vulnerabilities catalog pull into CVE threat indicators (admin-auth)", + false, + ), ] } -fn buyer_evidence_endpoint(id: &str, method: &str, path: &str, content_type: &str, proves: &str, required_for_sale: bool) -> BuyerEvidenceEndpoint { - BuyerEvidenceEndpoint { id: id.to_string(), method: method.to_string(), path: path.to_string(), content_type: content_type.to_string(), proves: proves.to_string(), required_for_sale } +fn buyer_evidence_endpoint( + id: &str, + method: &str, + path: &str, + content_type: &str, + proves: &str, + required_for_sale: bool, +) -> BuyerEvidenceEndpoint { + BuyerEvidenceEndpoint { + id: id.to_string(), + method: method.to_string(), + path: path.to_string(), + content_type: content_type.to_string(), + proves: proves.to_string(), + required_for_sale, + } } fn unix_now() -> u64 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() } pub fn readiness_check(id: &str, passed: bool, evidence: &str) -> ReadinessCheck { ReadinessCheck { id: id.to_string(), - status: if passed { ReadinessStatus::Pass } else { ReadinessStatus::Fail }, + status: if passed { + ReadinessStatus::Pass + } else { + ReadinessStatus::Fail + }, evidence: evidence.to_string(), } } @@ -968,25 +1397,54 @@ pub fn export_dnsbl_zone(origin: &str, entries: &[DnsblEntry]) -> String { let mut out = format!("$ORIGIN {}.\n$TTL 300\n", sanitize_zone_origin(origin)); for entry in entries { if let IpAddr::V4(address) = entry.address { + // The response code is emitted as a bare, unquoted A-record token, so + // it must be a valid IPv4 loopback literal (RFC 5782: DNSBL answers + // live in 127.0.0.0/8). `validate_dnsbl` enforces this at the + // create/import boundary, but the persisted-state deserializer is an + // untrusted surface that is not re-validated on load, so a state file + // can carry a code outside 127/8, an IPv6 literal, or a zone-injection + // string (e.g. a newline plus a forged `IN TXT` line). Re-enforce the + // invariant here and re-render the canonical form so no non-loopback, + // non-IPv4, or attacker-controlled bytes survive into the zone. let code = match IpAddr::from_str(&entry.code) { Ok(IpAddr::V4(code)) if code.octets()[0] == 127 => code, _ => continue, }; let name = reverse_ipv4_for_dnsbl(address.octets()); out.push_str(&format!("{} IN A {}\n", name, code)); - out.push_str(&format!("{} IN TXT \"{}\"\n", name, escape_txt(&format!("{} source={}", entry.reason, entry.source)))); + out.push_str(&format!( + "{} IN TXT \"{}\"\n", + name, + escape_txt(&format!("{} source={}", entry.reason, entry.source)) + )); } } out } +/// Sanitize a DNS zone origin so operator/threat-feed input can never break out +/// of the generated zone file. A legitimate origin is a domain name, so only +/// letters, digits, `-`, `_`, and `.` are kept; every other byte (newline, +/// quote, space, control char) is dropped. Leading/trailing dots are trimmed +/// because the caller re-appends the root dot. Empty input falls back to the +/// RFC 6761 reserved `.invalid` TLD, which is guaranteed non-resolvable. fn sanitize_zone_origin(origin: &str) -> String { - let filtered: String = origin.trim().chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(); + let filtered: String = origin + .trim() + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + .collect(); let trimmed = filtered.trim_matches('.'); - if trimmed.is_empty() { "dnsbl.invalid".to_string() } else { trimmed.to_string() } + if trimmed.is_empty() { + "dnsbl.invalid".to_string() + } else { + trimmed.to_string() + } } -pub fn reverse_ipv4_for_dnsbl(octets: [u8; 4]) -> String { format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0]) } +pub fn reverse_ipv4_for_dnsbl(octets: [u8; 4]) -> String { + format!("{}.{}.{}.{}", octets[3], octets[2], octets[1], octets[0]) +} fn escape_txt(value: &str) -> String { let mut out = String::with_capacity(value.len()); @@ -994,9 +1452,15 @@ fn escape_txt(value: &str) -> String { match ch { '\\' => out.push_str("\\\\"), '"' => out.push_str("\\\""), + // Control characters (notably raw newlines) would otherwise terminate + // the single-line TXT record and let a crafted reason/source inject + // subsequent zone lines. Emit them as BIND decimal escapes (`\DDD`) + // so the payload stays on one fully-quoted line. c if c.is_control() => { let mut buf = [0u8; 4]; - for &b in c.encode_utf8(&mut buf).as_bytes() { out.push_str(&format!("\\{b:03}")); } + for &b in c.encode_utf8(&mut buf).as_bytes() { + out.push_str(&format!("\\{b:03}")); + } } c => out.push(c), } @@ -1008,48 +1472,90 @@ fn escape_txt(value: &str) -> String { mod tests { use super::*; - #[test] - fn predecessor_state_defaults_dnsbl_ownership() { - let predecessor = r#"{ - "routes":[],"threats":[],"operator_threat_keys":[],"dnsbl":[], - "events":[],"next_event_id":1, - "threat_feed_ownership":[{"feed_id":"feed-a","threat_keys":[]}] - }"#; - let loaded: AppData = serde_json::from_str(predecessor).unwrap(); - assert!(loaded.operator_dnsbl_keys.is_empty()); - assert_eq!(loaded.threat_feed_ownership.len(), 1); - assert!(loaded.threat_feed_ownership[0].dnsbl_keys.is_empty()); - } - #[test] fn score_request_matches_client_ip_threat_indicators() { - let threats = vec![ThreatIndicator { value: "203.0.113.50".to_string(), indicator_type: "client_ip".to_string(), severity: Severity::High, source: "engine:coraza".to_string(), ttl_seconds: 3600 }]; - let hit = score_request("/index", None, "", Some("203.0.113.50".parse().unwrap()), &threats, &[]); + let threats = vec![ThreatIndicator { + value: "203.0.113.50".to_string(), + indicator_type: "client_ip".to_string(), + severity: Severity::High, + source: "engine:coraza".to_string(), + ttl_seconds: 3600, + }]; + let hit = score_request( + "/index", + None, + "", + Some("203.0.113.50".parse().unwrap()), + &threats, + &[], + ); assert!(hit.score >= BLOCK_SCORE); assert!(hit.reason.contains("client_ip")); - let miss = score_request("/index", None, "", Some("198.51.100.1".parse().unwrap()), &threats, &[]); + let miss = score_request( + "/index", + None, + "", + Some("198.51.100.1".parse().unwrap()), + &threats, + &[], + ); assert_eq!(miss.score, 0); } #[test] fn score_request_never_content_matches_cve_indicators() { - let threats = vec![ThreatIndicator { value: "CVE-2021-44228".to_string(), indicator_type: "cve".to_string(), severity: Severity::Critical, source: "feed:cisa-kev".to_string(), ttl_seconds: 86_400 }]; - let hit = score_request("/api/cve/CVE-2021-44228", None, "looking up CVE-2021-44228 details", None, &threats, &[]); + // A CVE indicator (e.g. from a CISA KEV import) is vulnerability + // metadata, not a request-content signature: a security-tooling + // request can legitimately carry the literal CVE string, and that + // must never contribute to the block score. + let threats = vec![ThreatIndicator { + value: "CVE-2021-44228".to_string(), + indicator_type: "cve".to_string(), + severity: Severity::Critical, + source: "feed:cisa-kev".to_string(), + ttl_seconds: 86_400, + }]; + let hit = score_request( + "/api/cve/CVE-2021-44228", + None, + "looking up CVE-2021-44228 details", + None, + &threats, + &[], + ); assert_eq!(hit.score, 0); assert_eq!(hit.reason, "no matching indicator"); } #[test] fn score_request_saturates_instead_of_overflowing_on_many_matches() { - let threats: Vec = (0..700).map(|i| ThreatIndicator { - value: "attack".to_string(), indicator_type: "keyword".to_string(), severity: Severity::Critical, - source: format!("feed-{i}"), ttl_seconds: 300, - }).collect(); + // Regression: `score` is a u16 accumulator. With enough matching + // indicators (each Critical = 100), a plain `+=` overflows u16 (>65535): + // a debug/overflow-checked build panics -- violating the WAF invariant + // that scoring never panics on arbitrary input -- and a release build + // wraps the score down to a tiny value, silently letting a maximally + // malicious request slip under the block threshold. Saturating + // arithmetic must clamp the score at u16::MAX so the request still + // scores as blockable. 700 * 100 = 70000 exceeds u16::MAX (65535). + let threats: Vec = (0..700) + .map(|i| ThreatIndicator { + value: "attack".to_string(), + indicator_type: "keyword".to_string(), + severity: Severity::Critical, + source: format!("feed-{i}"), + ttl_seconds: 300, + }) + .collect(); + let scored = score_request("/attack", None, "attack", None, &threats, &[]); + assert_eq!(scored.score, u16::MAX); assert!(scored.score >= BLOCK_SCORE); } + /// Assert every double quote inside a TXT payload is backslash-escaped, i.e. + /// preceded by an odd run of backslashes. Mirrors the fuzz/proptest invariant + /// so regressions in zone escaping fail as a plain unit test too. fn assert_txt_quotes_escaped(zone: &str) { for line in zone.lines().filter(|l| l.contains(" IN TXT ")) { let start = line.find('"').expect("TXT record has an opening quote"); @@ -1059,8 +1565,14 @@ mod tests { if b == b'"' { let mut backslashes = 0usize; let mut j = idx; - while j > 0 && payload[j - 1] == b'\\' { backslashes += 1; j -= 1; } - assert!(backslashes % 2 == 1, "unescaped quote in TXT payload: {line:?}"); + while j > 0 && payload[j - 1] == b'\\' { + backslashes += 1; + j -= 1; + } + assert!( + backslashes % 2 == 1, + "unescaped quote in TXT payload: {line:?}" + ); } } } @@ -1068,20 +1580,41 @@ mod tests { #[test] fn export_dnsbl_zone_resists_origin_zone_injection() { + // Reproduces the fuzz crash: a crafted origin carrying a newline plus a + // forged `IN TXT` line with bare double quotes must not break out of the + // generated zone. let zone = export_dnsbl_zone( "dn\nner\"\"\"\"\"\"\"\" IN TXT \"\"\"\"\"\"\"\"\"\"\"\"\"\"\"\";eed", - &[DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "127.0.0.2".to_string(), reason: "scanner".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }], + &[DnsblEntry { + address: "192.0.2.10".parse().unwrap(), + code: "127.0.0.2".to_string(), + reason: "scanner".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + prefix_len: None, + }], ); assert!(zone.starts_with("$ORIGIN ")); + // The origin is sanitized down to DNS-safe characters on a single line. assert_eq!(zone.lines().next().unwrap(), "$ORIGIN dnnerINTXTeed."); assert_txt_quotes_escaped(&zone); } #[test] fn export_dnsbl_zone_escapes_quotes_and_backslashes_in_reason() { + // Reason carrying both a backslash and a double quote must be escaped so + // the quote stays inside the payload (`\"`) and the backslash is doubled + // (`\\`); this also exercises the escaped-quote path of the checker. let zone = export_dnsbl_zone( "dnsbl.example", - &[DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "127.0.0.2".to_string(), reason: "back\\slash and \"quote\"".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }], + &[DnsblEntry { + address: "192.0.2.10".parse().unwrap(), + code: "127.0.0.2".to_string(), + reason: "back\\slash and \"quote\"".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + prefix_len: None, + }], ); assert!(zone.contains("10.2.0.192 IN TXT \"back\\\\slash and \\\"quote\\\" source=unit\"")); assert_txt_quotes_escaped(&zone); @@ -1089,13 +1622,30 @@ mod tests { #[test] fn export_dnsbl_zone_rejects_non_ip_code_injection() { + // A `code` that is not a valid IP literal would become a bare A-record + // token; a newline-bearing value must be dropped, never rendered. let zone = export_dnsbl_zone( "dnsbl.example", &[ - DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "127.0.0.2\n10.2.0.192 IN TXT \"pwned".to_string(), reason: "scanner".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }, - DnsblEntry { address: "192.0.2.20".parse().unwrap(), code: "127.0.0.9".to_string(), reason: "ok".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }, + DnsblEntry { + address: "192.0.2.10".parse().unwrap(), + code: "127.0.0.2\n10.2.0.192 IN TXT \"pwned".to_string(), + reason: "scanner".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + prefix_len: None, + }, + DnsblEntry { + address: "192.0.2.20".parse().unwrap(), + code: "127.0.0.9".to_string(), + reason: "ok".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + prefix_len: None, + }, ], ); + // The malformed-code entry is skipped entirely; the valid one renders. assert!(!zone.contains("pwned")); assert!(zone.contains("20.2.0.192 IN A 127.0.0.9")); assert_txt_quotes_escaped(&zone); @@ -1103,33 +1653,90 @@ mod tests { #[test] fn export_dnsbl_zone_omits_non_loopback_response_codes() { + // `validate_dnsbl` gates the create/import path to `127.0.0.0/8`, but the + // persisted-state deserializer (a documented untrusted-input surface) is + // NOT re-validated on load, so a state file can carry a DNSBL entry whose + // `code` is a valid IP outside 127/8 — or an IPv6 literal. The zone export + // is the output boundary that publishes each code as a bare A-record + // token, so it must re-enforce the "response code in 127.0.0.0/8" + // invariant itself: a non-loopback IPv4 answer breaks RFC 5782 semantics + // for every DNSBL consumer, and an IPv6 literal yields a syntactically + // invalid A record that fails the whole authoritative zone load. let zone = export_dnsbl_zone( "dnsbl.example", &[ - DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "8.8.8.8".to_string(), reason: "spoofed".to_string(), source: "state-file".to_string(), ttl_seconds: 300, prefix_len: None }, - DnsblEntry { address: "192.0.2.20".parse().unwrap(), code: "::1".to_string(), reason: "spoofed6".to_string(), source: "state-file".to_string(), ttl_seconds: 300, prefix_len: None }, - DnsblEntry { address: "192.0.2.30".parse().unwrap(), code: "127.0.0.4".to_string(), reason: "ok".to_string(), source: "state-file".to_string(), ttl_seconds: 300, prefix_len: None }, + DnsblEntry { + address: "192.0.2.10".parse().unwrap(), + // Valid IPv4, but NOT in 127.0.0.0/8. + code: "8.8.8.8".to_string(), + reason: "spoofed".to_string(), + source: "state-file".to_string(), + ttl_seconds: 300, + prefix_len: None, + }, + DnsblEntry { + address: "192.0.2.20".parse().unwrap(), + // IPv6 literal — never a legal A-record response code. + code: "::1".to_string(), + reason: "spoofed6".to_string(), + source: "state-file".to_string(), + ttl_seconds: 300, + prefix_len: None, + }, + DnsblEntry { + address: "192.0.2.30".parse().unwrap(), + // Valid loopback code — must still render. + code: "127.0.0.4".to_string(), + reason: "ok".to_string(), + source: "state-file".to_string(), + ttl_seconds: 300, + prefix_len: None, + }, ], ); - assert!(!zone.contains("IN A 8.8.8.8"), "non-127/8 A record leaked into zone: {zone}"); - assert!(!zone.contains("IN A ::1"), "IPv6 A record leaked into zone: {zone}"); + // No non-loopback or non-IPv4 answer may escape into the published zone. + assert!( + !zone.contains("IN A 8.8.8.8"), + "non-127/8 A record leaked into zone: {zone}" + ); + assert!( + !zone.contains("IN A ::1"), + "IPv6 A record leaked into zone: {zone}" + ); + // Every emitted A record's response code is an IPv4 loopback address. for line in zone.lines().filter(|l| l.contains(" IN A ")) { let code = line.rsplit(" IN A ").next().unwrap().trim(); match IpAddr::from_str(code).expect("A-record code is an IP literal") { - IpAddr::V4(v4) => assert_eq!(v4.octets()[0], 127, "non-loopback DNSBL response code published: {code}"), + IpAddr::V4(v4) => assert_eq!( + v4.octets()[0], + 127, + "non-loopback DNSBL response code published: {code}" + ), IpAddr::V6(_) => panic!("IPv6 DNSBL response code published: {code}"), } } + // The legitimate loopback entry is unaffected. assert!(zone.contains("30.2.0.192 IN A 127.0.0.4")); assert_txt_quotes_escaped(&zone); } #[test] fn export_dnsbl_zone_escapes_control_chars_in_reason() { + // A raw newline in reason/source must be neutralized so the TXT record + // stays on one line and cannot inject subsequent zone entries. let zone = export_dnsbl_zone( "dnsbl.example", - &[DnsblEntry { address: "192.0.2.10".parse().unwrap(), code: "127.0.0.2".to_string(), reason: "line1\n10.2.0.192 IN TXT \"break".to_string(), source: "unit".to_string(), ttl_seconds: 300, prefix_len: None }], + &[DnsblEntry { + address: "192.0.2.10".parse().unwrap(), + code: "127.0.0.2".to_string(), + reason: "line1\n10.2.0.192 IN TXT \"break".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + prefix_len: None, + }], ); + // No raw newline survives inside the TXT payload: the whole record, + // including the injected `IN TXT` text, stays on a single line. assert_eq!(zone.lines().filter(|l| l.contains(" IN TXT ")).count(), 1); assert!(zone.contains("\\010")); assert_txt_quotes_escaped(&zone); @@ -1139,18 +1746,26 @@ mod tests { fn sanitize_zone_origin_falls_back_when_empty() { assert_eq!(sanitize_zone_origin("\"\n\t \""), "dnsbl.invalid"); assert_eq!(sanitize_zone_origin("dnsbl.example."), "dnsbl.example"); - assert_eq!(sanitize_zone_origin("dnsbl_feed.example-1"), "dnsbl_feed.example-1"); + assert_eq!( + sanitize_zone_origin("dnsbl_feed.example-1"), + "dnsbl_feed.example-1" + ); } #[test] fn anomaly_signal_flags_metacharacters_and_entropy() { + // Metacharacter density on a short payload (entropy check length-gated out). let (score, reason) = anomaly_signal("ac'd\"e(f)g;h|i&j").unwrap(); assert_eq!(score, 15); assert!(reason.contains("metacharacters")); + + // High-entropy encoded blob (40+ bytes, no metacharacters). let blob = "aGVsbG8Xd29ybGQ0Zm9vYmFyMTIzNDU2Nzg5MDBhYmNkZWZn"; let (score, reason) = anomaly_signal(blob).unwrap(); assert_eq!(score, 10); assert!(reason.contains("entropy")); + + // Long but low-entropy (repeated byte) and ordinary short text: not flagged. assert!(anomaly_signal(&"a".repeat(60)).is_none()); assert!(anomaly_signal("/account/profile?tab=settings").is_none()); } @@ -1164,8 +1779,30 @@ mod tests { #[test] fn records_audit_logs_with_monotonic_ids() { let mut data = AppData::seeded(); - let first = record_audit_log(&mut data, NewAuditLogEntry { timestamp_unix: 10, actor: "operator@example.com".to_string(), action: "upsert_route".to_string(), resource: "route".to_string(), resource_id: "edge".to_string(), outcome: "success".to_string() }); - let second = record_audit_log(&mut data, NewAuditLogEntry { timestamp_unix: 11, actor: "operator@example.com".to_string(), action: "update_license".to_string(), resource: "commercial_license".to_string(), resource_id: "cwlab-enterprise".to_string(), outcome: "success".to_string() }); + + let first = record_audit_log( + &mut data, + NewAuditLogEntry { + timestamp_unix: 10, + actor: "operator@example.com".to_string(), + action: "upsert_route".to_string(), + resource: "route".to_string(), + resource_id: "edge".to_string(), + outcome: "success".to_string(), + }, + ); + let second = record_audit_log( + &mut data, + NewAuditLogEntry { + timestamp_unix: 11, + actor: "operator@example.com".to_string(), + action: "update_license".to_string(), + resource: "commercial_license".to_string(), + resource_id: "cwlab-enterprise".to_string(), + outcome: "success".to_string(), + }, + ); + assert_eq!(first.id, 1); assert_eq!(second.id, 2); assert_eq!(data.audit_logs.len(), 2); From 0950ad4d2833b0c374251d52ad3ab9e8284461d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:49:27 +0900 Subject: [PATCH 18/34] test(feed): pin operator DNSBL precedence and write count --- tests/threat_feed_dnsbl_ownership.rs | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/threat_feed_dnsbl_ownership.rs b/tests/threat_feed_dnsbl_ownership.rs index ba7c3cb..a9da7f0 100644 --- a/tests/threat_feed_dnsbl_ownership.rs +++ b/tests/threat_feed_dnsbl_ownership.rs @@ -177,3 +177,51 @@ async fn feed_refresh_preserves_operator_managed_dnsbl_payload() { assert_eq!(entry.source, "operator"); assert_eq!(entry.ttl_seconds, 86400); } + +#[tokio::test] +async fn feed_import_does_not_overwrite_operator_dnsbl_or_count_a_skipped_write() { + let app = build_app(AppState::seeded(Some("secret".to_string()))); + let address = "203.0.113.213"; + + let operator = app + .clone() + .oneshot(json_request( + Method::POST, + "/api/dnsbl", + json!({ + "address": address, + "code": "127.0.0.88", + "reason": "operator-owned payload", + "source": "operator", + "ttl_seconds": 86400, + "prefix_len": null + }), + )) + .await + .unwrap(); + assert_eq!(operator.status(), StatusCode::CREATED); + + let imported = app + .clone() + .oneshot(json_request( + Method::POST, + "/api/threat-feeds/import", + feed_payload("feed-a", "feed:a", &[address]), + )) + .await + .unwrap(); + assert_eq!(imported.status(), StatusCode::CREATED); + let bytes = to_bytes(imported.into_body(), usize::MAX).await.unwrap(); + let result: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(result["upserted_dnsbl"], 0); + + let entries = dnsbl(&app); + let entry = entries + .iter() + .find(|entry| entry.address.to_string() == address) + .expect("operator-owned DNSBL entry must remain present"); + assert_eq!(entry.code, "127.0.0.88"); + assert_eq!(entry.reason, "operator-owned payload"); + assert_eq!(entry.source, "operator"); + assert_eq!(entry.ttl_seconds, 86400); +} From ba5dd624e50eec63efc453a54beb57cbe28295a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 15:03:28 +0900 Subject: [PATCH 19/34] test(security): isolate DNSBL withdrawal reconciliation --- tests/threat_feed_dnsbl_ownership.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/threat_feed_dnsbl_ownership.rs b/tests/threat_feed_dnsbl_ownership.rs index a9da7f0..4130b96 100644 --- a/tests/threat_feed_dnsbl_ownership.rs +++ b/tests/threat_feed_dnsbl_ownership.rs @@ -71,7 +71,7 @@ async fn feed_refresh_reaps_dnsbl_entries_it_withdraws() { .oneshot(json_request( Method::POST, "/api/threat-feeds/import", - feed_payload("feed-a", "feed:a", &[]), + feed_payload("feed-a", "feed:a", &["203.0.113.250"]), )) .await .unwrap(); @@ -108,7 +108,7 @@ async fn feed_refresh_preserves_dnsbl_still_owned_by_another_feed() { .oneshot(json_request( Method::POST, "/api/threat-feeds/import", - feed_payload("feed-a", "feed:a", &[]), + feed_payload("feed-a", "feed:a", &["203.0.113.250"]), )) .await .unwrap(); @@ -161,7 +161,7 @@ async fn feed_refresh_preserves_operator_managed_dnsbl_payload() { .oneshot(json_request( Method::POST, "/api/threat-feeds/import", - feed_payload("feed-a", "feed:a", &[]), + feed_payload("feed-a", "feed:a", &["203.0.113.250"]), )) .await .unwrap(); From b6581d45d1b4eacb0ceeb37e2114303d94a0c5b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 23:37:10 +0900 Subject: [PATCH 20/34] style: expose DNSBL ownership RED through rustfmt --- src/misp_import.rs | 13 ++------ tests/misp_to_ids_admission.rs | 50 +++++++++++++++++----------- tests/threat_feed_dnsbl_ownership.rs | 6 +++- 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/misp_import.rs b/src/misp_import.rs index d95c78c..b12e3d1 100644 --- a/src/misp_import.rs +++ b/src/misp_import.rs @@ -119,14 +119,7 @@ pub fn misp_material_from_value( } for (attr, severity) in loose_attributes { - match materialize_attribute( - attr, - source, - ttl_seconds, - severity, - "misp-attribute", - true, - ) { + match materialize_attribute(attr, source, ttl_seconds, severity, "misp-attribute", true) { AttributeOutcome::Mapped { threats: t, dnsbl: d, @@ -208,9 +201,7 @@ fn active_by_deleted_marker(deleted: Option<&serde_json::Value>) -> bool { deleted .map(|value| match value { serde_json::Value::Bool(is_deleted) => !*is_deleted, - serde_json::Value::String(value) => { - value == "0" || value.eq_ignore_ascii_case("false") - } + serde_json::Value::String(value) => value == "0" || value.eq_ignore_ascii_case("false"), serde_json::Value::Number(value) => value.as_u64() == Some(0), _ => false, }) diff --git a/tests/misp_to_ids_admission.rs b/tests/misp_to_ids_admission.rs index 2ab7dc2..305777a 100644 --- a/tests/misp_to_ids_admission.rs +++ b/tests/misp_to_ids_admission.rs @@ -34,18 +34,24 @@ fn deleted_misp_attributes_cannot_authorize_enforcement() { let material = misp_import::parse_misp_document(raw, "misp:test", 60).unwrap(); assert_eq!(material.threats.len(), 3); - assert!(material - .threats - .iter() - .any(|threat| threat.value == "active-bool.example")); - assert!(material - .threats - .iter() - .any(|threat| threat.value == "active-string.example")); - assert!(material - .threats - .iter() - .any(|threat| threat.value == "active-omitted.example")); + assert!( + material + .threats + .iter() + .any(|threat| threat.value == "active-bool.example") + ); + assert!( + material + .threats + .iter() + .any(|threat| threat.value == "active-string.example") + ); + assert!( + material + .threats + .iter() + .any(|threat| threat.value == "active-omitted.example") + ); assert_eq!(material.skipped_attributes, 3); } @@ -98,13 +104,17 @@ fn deleted_or_ambiguous_misp_objects_cannot_authorize_nested_attributes() { let material = misp_import::parse_misp_document(raw, "misp:test", 60).unwrap(); assert_eq!(material.threats.len(), 2); - assert!(material - .threats - .iter() - .any(|threat| threat.value == "active-object-bool.example")); - assert!(material - .threats - .iter() - .any(|threat| threat.value == "active-object-omitted.example")); + assert!( + material + .threats + .iter() + .any(|threat| threat.value == "active-object-bool.example") + ); + assert!( + material + .threats + .iter() + .any(|threat| threat.value == "active-object-omitted.example") + ); assert_eq!(material.skipped_attributes, 3); } diff --git a/tests/threat_feed_dnsbl_ownership.rs b/tests/threat_feed_dnsbl_ownership.rs index 4130b96..672a8ea 100644 --- a/tests/threat_feed_dnsbl_ownership.rs +++ b/tests/threat_feed_dnsbl_ownership.rs @@ -64,7 +64,11 @@ async fn feed_refresh_reaps_dnsbl_entries_it_withdraws() { .await .unwrap(); assert_eq!(first.status(), StatusCode::CREATED); - assert!(dnsbl(&app).iter().any(|entry| entry.address.to_string() == "203.0.113.210")); + assert!( + dnsbl(&app) + .iter() + .any(|entry| entry.address.to_string() == "203.0.113.210") + ); let refresh = app .clone() From 46615df5caca8e6ef1738aba1995f681524b585d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 01:06:46 +0900 Subject: [PATCH 21/34] test(security): execute DNSBL ownership RED assertions --- tests/threat_feed_dnsbl_ownership.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/threat_feed_dnsbl_ownership.rs b/tests/threat_feed_dnsbl_ownership.rs index 672a8ea..980e264 100644 --- a/tests/threat_feed_dnsbl_ownership.rs +++ b/tests/threat_feed_dnsbl_ownership.rs @@ -66,6 +66,7 @@ async fn feed_refresh_reaps_dnsbl_entries_it_withdraws() { assert_eq!(first.status(), StatusCode::CREATED); assert!( dnsbl(&app) + .await .iter() .any(|entry| entry.address.to_string() == "203.0.113.210") ); @@ -83,6 +84,7 @@ async fn feed_refresh_reaps_dnsbl_entries_it_withdraws() { assert!( !dnsbl(&app) + .await .iter() .any(|entry| entry.address.to_string() == "203.0.113.210"), "withdrawn feed DNSBL material must not keep blocking after refresh" @@ -120,6 +122,7 @@ async fn feed_refresh_preserves_dnsbl_still_owned_by_another_feed() { assert!( dnsbl(&app) + .await .iter() .any(|entry| entry.address.to_string() == address), "one feed cannot delete a DNSBL address still claimed by another feed" @@ -171,7 +174,7 @@ async fn feed_refresh_preserves_operator_managed_dnsbl_payload() { .unwrap(); assert_eq!(refresh.status(), StatusCode::CREATED); - let entries = dnsbl(&app); + let entries = dnsbl(&app).await; let entry = entries .iter() .find(|entry| entry.address.to_string() == address) @@ -219,7 +222,7 @@ async fn feed_import_does_not_overwrite_operator_dnsbl_or_count_a_skipped_write( let result: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(result["upserted_dnsbl"], 0); - let entries = dnsbl(&app); + let entries = dnsbl(&app).await; let entry = entries .iter() .find(|entry| entry.address.to_string() == address) From 6248be07657ada547a72b23b0ea50af3a88be20f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:08:55 +0900 Subject: [PATCH 22/34] test(security): cover DNSBL ownership persistence --- tests/threat_feed_dnsbl_persistence.rs | 195 +++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 tests/threat_feed_dnsbl_persistence.rs diff --git a/tests/threat_feed_dnsbl_persistence.rs b/tests/threat_feed_dnsbl_persistence.rs new file mode 100644 index 0000000..7322a38 --- /dev/null +++ b/tests/threat_feed_dnsbl_persistence.rs @@ -0,0 +1,195 @@ +use axum::{ + body::{Body, to_bytes}, + http::{Method, Request, StatusCode}, +}; +use serde_json::json; +use std::{ + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tower::ServiceExt; +use waf_ids_ai_soc::{AppConfig, AppState, DnsblEntry, build_app}; + +fn json_request(method: Method, uri: &str, body: serde_json::Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .header("x-admin-token", "secret") + .body(Body::from(body.to_string())) + .unwrap() +} + +fn feed_payload(feed_id: &str, source: &str, addresses: &[&str]) -> serde_json::Value { + json!({ + "feed_id": feed_id, + "source": source, + "ttl_seconds": 3600, + "threats": [], + "dnsbl": addresses.iter().map(|address| json!({ + "address": address, + "code": "127.0.0.2", + "reason": format!("{feed_id} verdict"), + "source": source, + "ttl_seconds": 3600, + "prefix_len": null + })).collect::>() + }) +} + +fn unique_state_path(test_name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + std::env::temp_dir().join(format!( + "wardnet-{test_name}-{}-{nonce}.json", + std::process::id() + )) +} + +fn file_config(path: &Path) -> AppConfig { + AppConfig { + admin_token: Some("secret".to_string()), + state_path: Some(path.to_path_buf()), + dnsbl_origin: AppConfig::DEFAULT_DNSBL_ORIGIN.to_string(), + event_limit: AppConfig::DEFAULT_EVENT_LIMIT, + } +} + +async fn dnsbl(app: &axum::Router) -> Vec { + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/dnsbl") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +async fn import_feed( + app: &axum::Router, + feed_id: &str, + source: &str, + addresses: &[&str], +) -> StatusCode { + app.clone() + .oneshot(json_request( + Method::POST, + "/api/threat-feeds/import", + feed_payload(feed_id, source, addresses), + )) + .await + .unwrap() + .status() +} + +#[tokio::test] +async fn dnsbl_feed_ownership_survives_restart_before_withdrawal() { + let path = unique_state_path("dnsbl-ownership-restart"); + let config = file_config(&path); + let target = "203.0.113.220"; + + let app = build_app(AppState::load(config.clone()).await.unwrap()); + assert_eq!( + import_feed(&app, "feed-a", "feed:a", &[target]).await, + StatusCode::CREATED + ); + drop(app); + + let app = build_app(AppState::load(config).await.unwrap()); + assert_eq!( + import_feed(&app, "feed-a", "feed:a", &["203.0.113.250"]).await, + StatusCode::CREATED + ); + assert!( + !dnsbl(&app) + .await + .iter() + .any(|entry| entry.address.to_string() == target), + "persisted feed ownership must still reap a withdrawn DNSBL address after restart" + ); + + let _ = std::fs::remove_file(path); +} + +#[tokio::test] +async fn predecessor_state_without_dnsbl_ownership_fields_remains_loadable() { + let path = unique_state_path("dnsbl-ownership-legacy-state"); + let config = file_config(&path); + + let state = AppState::load(config.clone()).await.unwrap(); + drop(state); + + let mut legacy: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let object = legacy.as_object_mut().expect("seeded state is an object"); + // These keys are intentionally absent in the predecessor schema. The GREEN + // implementation must add them with serde defaults rather than requiring a + // one-shot migration that makes an existing Wardnet state file unreadable. + object.remove("operator_dnsbl_addresses"); + object.insert( + "threat_feed_ownership".to_string(), + json!([{"feed_id": "legacy-feed", "threat_keys": []}]), + ); + std::fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + + AppState::load(config) + .await + .expect("pre-DNSBL-ownership state must deserialize with empty ownership defaults"); + + let _ = std::fs::remove_file(path); +} + +#[tokio::test] +async fn failed_persistence_rolls_back_dnsbl_ownership_before_retry() { + let path = unique_state_path("dnsbl-ownership-rollback"); + let backup = path.with_extension("backup.json"); + let config = file_config(&path); + let target = "203.0.113.221"; + let app = build_app(AppState::load(config).await.unwrap()); + + assert_eq!( + import_feed(&app, "feed-a", "feed:a", &[target]).await, + StatusCode::CREATED + ); + + std::fs::rename(&path, &backup).unwrap(); + std::fs::create_dir(&path).unwrap(); + assert_eq!( + import_feed(&app, "feed-a", "feed:a", &["203.0.113.250"]).await, + StatusCode::INTERNAL_SERVER_ERROR, + "replacing a state file with a directory must make persistence fail" + ); + assert!( + dnsbl(&app) + .await + .iter() + .any(|entry| entry.address.to_string() == target), + "failed persistence must restore the complete pre-mutation DNSBL snapshot" + ); + + std::fs::remove_dir(&path).unwrap(); + std::fs::rename(&backup, &path).unwrap(); + assert_eq!( + import_feed(&app, "feed-a", "feed:a", &["203.0.113.250"]).await, + StatusCode::CREATED + ); + assert!( + !dnsbl(&app) + .await + .iter() + .any(|entry| entry.address.to_string() == target), + "retry after rollback must see the original ownership and reap the withdrawn address" + ); + + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_file(backup); +} From 6b3b0b07637d87fd65a4d2b0d2f8bf0bb835908f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:13:58 +0900 Subject: [PATCH 23/34] fix(security): persist DNSBL ownership keys --- crates/waf-ids-core/src/lib.rs | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index f9673e0..5496ff5 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -14,6 +14,10 @@ pub struct AppData { #[serde(default)] pub operator_threat_keys: Vec, pub dnsbl: Vec, + /// Stable DNSBL identities created or overwritten through the operator API. + /// Feed refreshes must not replace or reap these addresses. + #[serde(default)] + pub operator_dnsbl_addresses: Vec, pub events: Vec, pub next_event_id: u64, #[serde(default)] @@ -55,6 +59,7 @@ impl AppData { ttl_seconds: 300, prefix_len: None, }], + operator_dnsbl_addresses: Vec::new(), events: Vec::new(), next_event_id: 1, audit_logs: Vec::new(), @@ -189,6 +194,10 @@ pub struct ThreatFeedStatus { pub struct ThreatFeedOwnership { pub feed_id: String, pub threat_keys: Vec, + /// DNSBL keys use the same stable identity as [`upsert_dnsbl`]: address. + /// The default keeps predecessor state files readable. + #[serde(default)] + pub dnsbl_addresses: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -573,6 +582,29 @@ pub fn replace_threat_feed_ownership( ownership.push(ThreatFeedOwnership { feed_id, threat_keys, + dnsbl_addresses: Vec::new(), + }); + Vec::new() + } +} + +/// Replaces one feed's DNSBL address snapshot and returns its preceding keys. +/// Feed and threat ownership share one persisted aggregate so a refresh can +/// reconcile both indicator kinds in the same AppData transaction. +pub fn replace_threat_feed_dnsbl_ownership( + ownership: &mut Vec, + feed_id: String, + dnsbl_addresses: Vec, +) -> Vec { + if let Some(existing) = ownership.iter_mut().find(|item| item.feed_id == feed_id) { + let previous = existing.dnsbl_addresses.clone(); + existing.dnsbl_addresses = dnsbl_addresses; + previous + } else { + ownership.push(ThreatFeedOwnership { + feed_id, + threat_keys: Vec::new(), + dnsbl_addresses, }); Vec::new() } @@ -1776,6 +1808,40 @@ mod tests { assert!(shannon_entropy(b"abcdefgh") > 2.9); } + #[test] + fn replaces_dnsbl_feed_ownership_without_losing_threat_ownership() { + let mut ownership = Vec::new(); + let threat_key = ThreatIndicatorKey { + indicator_type: "domain".to_string(), + value: "bad.example".to_string(), + source: "feed:a".to_string(), + }; + replace_threat_feed_ownership( + &mut ownership, + "feed-a".to_string(), + vec![threat_key.clone()], + ); + assert!(replace_threat_feed_dnsbl_ownership( + &mut ownership, + "feed-a".to_string(), + vec!["203.0.113.7".parse().unwrap()], + ) + .is_empty()); + assert_eq!(ownership[0].threat_keys, vec![threat_key]); + assert_eq!( + replace_threat_feed_dnsbl_ownership( + &mut ownership, + "feed-a".to_string(), + vec!["203.0.113.8".parse().unwrap()], + ), + vec!["203.0.113.7".parse::().unwrap()] + ); + assert_eq!( + ownership[0].dnsbl_addresses, + vec!["203.0.113.8".parse::().unwrap()] + ); + } + #[test] fn records_audit_logs_with_monotonic_ids() { let mut data = AppData::seeded(); From 28d0ac12d37b4c97ea58b2d55831a6c1e7b9cf98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 03:18:57 +0900 Subject: [PATCH 24/34] test(security): keep DNSBL persistence RED compile-clean --- crates/waf-ids-core/src/lib.rs | 66 ---------------------------------- 1 file changed, 66 deletions(-) diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index 5496ff5..f9673e0 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -14,10 +14,6 @@ pub struct AppData { #[serde(default)] pub operator_threat_keys: Vec, pub dnsbl: Vec, - /// Stable DNSBL identities created or overwritten through the operator API. - /// Feed refreshes must not replace or reap these addresses. - #[serde(default)] - pub operator_dnsbl_addresses: Vec, pub events: Vec, pub next_event_id: u64, #[serde(default)] @@ -59,7 +55,6 @@ impl AppData { ttl_seconds: 300, prefix_len: None, }], - operator_dnsbl_addresses: Vec::new(), events: Vec::new(), next_event_id: 1, audit_logs: Vec::new(), @@ -194,10 +189,6 @@ pub struct ThreatFeedStatus { pub struct ThreatFeedOwnership { pub feed_id: String, pub threat_keys: Vec, - /// DNSBL keys use the same stable identity as [`upsert_dnsbl`]: address. - /// The default keeps predecessor state files readable. - #[serde(default)] - pub dnsbl_addresses: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -582,29 +573,6 @@ pub fn replace_threat_feed_ownership( ownership.push(ThreatFeedOwnership { feed_id, threat_keys, - dnsbl_addresses: Vec::new(), - }); - Vec::new() - } -} - -/// Replaces one feed's DNSBL address snapshot and returns its preceding keys. -/// Feed and threat ownership share one persisted aggregate so a refresh can -/// reconcile both indicator kinds in the same AppData transaction. -pub fn replace_threat_feed_dnsbl_ownership( - ownership: &mut Vec, - feed_id: String, - dnsbl_addresses: Vec, -) -> Vec { - if let Some(existing) = ownership.iter_mut().find(|item| item.feed_id == feed_id) { - let previous = existing.dnsbl_addresses.clone(); - existing.dnsbl_addresses = dnsbl_addresses; - previous - } else { - ownership.push(ThreatFeedOwnership { - feed_id, - threat_keys: Vec::new(), - dnsbl_addresses, }); Vec::new() } @@ -1808,40 +1776,6 @@ mod tests { assert!(shannon_entropy(b"abcdefgh") > 2.9); } - #[test] - fn replaces_dnsbl_feed_ownership_without_losing_threat_ownership() { - let mut ownership = Vec::new(); - let threat_key = ThreatIndicatorKey { - indicator_type: "domain".to_string(), - value: "bad.example".to_string(), - source: "feed:a".to_string(), - }; - replace_threat_feed_ownership( - &mut ownership, - "feed-a".to_string(), - vec![threat_key.clone()], - ); - assert!(replace_threat_feed_dnsbl_ownership( - &mut ownership, - "feed-a".to_string(), - vec!["203.0.113.7".parse().unwrap()], - ) - .is_empty()); - assert_eq!(ownership[0].threat_keys, vec![threat_key]); - assert_eq!( - replace_threat_feed_dnsbl_ownership( - &mut ownership, - "feed-a".to_string(), - vec!["203.0.113.8".parse().unwrap()], - ), - vec!["203.0.113.7".parse::().unwrap()] - ); - assert_eq!( - ownership[0].dnsbl_addresses, - vec!["203.0.113.8".parse::().unwrap()] - ); - } - #[test] fn records_audit_logs_with_monotonic_ids() { let mut data = AppData::seeded(); From 340f20b1898099bbf2b58a2a2cbd34c8f6057b35 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 07:32:08 +0900 Subject: [PATCH 25/34] test(security): align DNSBL ownership predecessor schema --- tests/threat_feed_dnsbl_persistence.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/threat_feed_dnsbl_persistence.rs b/tests/threat_feed_dnsbl_persistence.rs index 7322a38..907d5c3 100644 --- a/tests/threat_feed_dnsbl_persistence.rs +++ b/tests/threat_feed_dnsbl_persistence.rs @@ -134,7 +134,7 @@ async fn predecessor_state_without_dnsbl_ownership_fields_remains_loadable() { // These keys are intentionally absent in the predecessor schema. The GREEN // implementation must add them with serde defaults rather than requiring a // one-shot migration that makes an existing Wardnet state file unreadable. - object.remove("operator_dnsbl_addresses"); + object.remove("operator_dnsbl_keys"); object.insert( "threat_feed_ownership".to_string(), json!([{"feed_id": "legacy-feed", "threat_keys": []}]), From 4e35e9fa5f905d6622b41db79e4bb6a7eebaadbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:12:11 +0900 Subject: [PATCH 26/34] chore: stage exact-head DNSBL ownership repair --- .github/workflows/wardnet-dnsbl-repair.yml | 427 +++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 .github/workflows/wardnet-dnsbl-repair.yml diff --git a/.github/workflows/wardnet-dnsbl-repair.yml b/.github/workflows/wardnet-dnsbl-repair.yml new file mode 100644 index 0000000..0e56953 --- /dev/null +++ b/.github/workflows/wardnet-dnsbl-repair.yml @@ -0,0 +1,427 @@ +name: Wardnet DNSBL ownership repair (temporary) + +on: + push: + branches: + - fix/misp-to-ids-fail-closed + paths: + - .github/workflows/wardnet-dnsbl-repair.yml + +permissions: + contents: write + +concurrency: + group: wardnet-dnsbl-repair-${{ github.ref }} + cancel-in-progress: false + +jobs: + repair: + runs-on: ubuntu-24.04 + steps: + - name: Checkout triggering head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Refuse stale-parent execution + shell: bash + run: | + set -euo pipefail + git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" + test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" + - name: Apply shared DNSBL ownership repair + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + + def replace_exact(path: str, old: str, new: str, expected: int = 1) -> None: + file = Path(path) + text = file.read_text() + count = text.count(old) + if count != expected: + raise SystemExit(f"{path}: expected {expected} occurrences, found {count}: {old[:80]!r}") + file.write_text(text.replace(old, new)) + + core = "crates/waf-ids-core/src/lib.rs" + root = "src/lib.rs" + + replace_exact( + core, + """ #[serde(default)] + pub operator_threat_keys: Vec, + pub dnsbl: Vec, +""", + """ #[serde(default)] + pub operator_threat_keys: Vec, + /// DNSBL identities independently managed through the operator API. + #[serde(default)] + pub operator_dnsbl_keys: Vec, + pub dnsbl: Vec, +""", + ) + replace_exact( + core, + """ operator_threat_keys: Vec::new(), + dnsbl: vec![DnsblEntry { +""", + """ operator_threat_keys: Vec::new(), + operator_dnsbl_keys: Vec::new(), + dnsbl: vec![DnsblEntry { +""", + ) + replace_exact( + core, + """} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommercialProfile { +""", + """} + +/// Stable DNSBL ownership identity. It deliberately matches [`upsert_dnsbl`], +/// whose physical row identity is the IP address rather than payload metadata. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct DnsblEntryKey(pub IpAddr); + +/// Returns the durable ownership identity for a DNSBL entry. +pub fn dnsbl_entry_key(entry: &DnsblEntry) -> DnsblEntryKey { + DnsblEntryKey(entry.address) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommercialProfile { +""", + ) + replace_exact( + core, + """pub struct ThreatFeedOwnership { + pub feed_id: String, + pub threat_keys: Vec, +} +""", + """pub struct ThreatFeedOwnership { + pub feed_id: String, + pub threat_keys: Vec, + /// DNSBL rows currently claimed by this feed snapshot. + #[serde(default)] + pub dnsbl_keys: Vec, +} +""", + ) + replace_exact( + core, + """ ownership.push(ThreatFeedOwnership { + feed_id, + threat_keys, + }); + Vec::new() + } +} + +pub fn record_audit_log""", + """ ownership.push(ThreatFeedOwnership { + feed_id, + threat_keys, + dnsbl_keys: Vec::new(), + }); + Vec::new() + } +} + +/// Replaces one feed's DNSBL snapshot ownership and returns its prior keys. +pub fn replace_threat_feed_dnsbl_ownership( + ownership: &mut Vec, + feed_id: String, + dnsbl_keys: Vec, +) -> Vec { + if let Some(existing) = ownership.iter_mut().find(|item| item.feed_id == feed_id) { + let previous = existing.dnsbl_keys.clone(); + existing.dnsbl_keys = dnsbl_keys; + previous + } else { + ownership.push(ThreatFeedOwnership { + feed_id, + threat_keys: Vec::new(), + dnsbl_keys, + }); + Vec::new() + } +} + +pub fn record_audit_log""", + ) + replace_exact( + core, + """ #[test] + fn records_audit_logs_with_monotonic_ids() { +""", + """ #[test] + fn replaces_threat_feed_dnsbl_ownership_and_returns_previous_keys() { + let first = DnsblEntryKey(\"203.0.113.1\".parse().unwrap()); + let second = DnsblEntryKey(\"203.0.113.2\".parse().unwrap()); + let mut ownership = Vec::new(); + + assert!(replace_threat_feed_dnsbl_ownership( + &mut ownership, + \"feed-a\".to_string(), + vec![first], + ) + .is_empty()); + assert_eq!(ownership[0].dnsbl_keys, vec![first]); + + let previous = replace_threat_feed_dnsbl_ownership( + &mut ownership, + \"feed-a\".to_string(), + vec![second], + ); + assert_eq!(previous, vec![first]); + assert_eq!(ownership[0].dnsbl_keys, vec![second]); + + assert!(replace_threat_feed_dnsbl_ownership( + &mut ownership, + \"feed-b\".to_string(), + vec![first], + ) + .is_empty()); + assert_eq!(ownership.len(), 2); + assert!(ownership[1].threat_keys.is_empty()); + } + + #[test] + fn dnsbl_ownership_fields_default_when_deserializing_predecessor_state() { + let key = DnsblEntryKey(\"203.0.113.3\".parse().unwrap()); + let mut data = AppData::seeded(); + data.operator_dnsbl_keys.push(key); + data.threat_feed_ownership.push(ThreatFeedOwnership { + feed_id: \"legacy-feed\".to_string(), + threat_keys: Vec::new(), + dnsbl_keys: vec![key], + }); + + let mut legacy = serde_json::to_value(data).unwrap(); + let object = legacy.as_object_mut().unwrap(); + object.remove(\"operator_dnsbl_keys\"); + object[\"threat_feed_ownership\"].as_array_mut().unwrap()[0] + .as_object_mut() + .unwrap() + .remove(\"dnsbl_keys\"); + + let loaded: AppData = serde_json::from_value(legacy).unwrap(); + assert!(loaded.operator_dnsbl_keys.is_empty()); + assert!(loaded.threat_feed_ownership[0].dnsbl_keys.is_empty()); + } + + #[test] + fn records_audit_logs_with_monotonic_ids() { +""", + ) + + replace_exact( + root, + """ AppData, BLOCK_SCORE, buyer_evidence_manifest_at, commercial_readiness_snapshot_at, + enforce_event_limit, kpi_snapshot_at, prometheus_exposition, rate_limit_step, record_audit_log, + replace_threat_feed_ownership, select_route, signature_catalog, threat_feed_freshness_snapshot, + threat_indicator_key, upsert_dnsbl, upsert_route, upsert_threat, upsert_threat_feed, +""", + """ AppData, BLOCK_SCORE, buyer_evidence_manifest_at, commercial_readiness_snapshot_at, + dnsbl_entry_key, enforce_event_limit, kpi_snapshot_at, prometheus_exposition, rate_limit_step, + record_audit_log, replace_threat_feed_dnsbl_ownership, replace_threat_feed_ownership, + select_route, signature_catalog, threat_feed_freshness_snapshot, threat_indicator_key, + upsert_dnsbl, upsert_route, upsert_threat, upsert_threat_feed, +""", + ) + replace_exact( + root, + """ CommercialProfile, CommercialReadiness, DnsblEntry, EnforcementMode, LicenseStatus, +""", + """ CommercialProfile, CommercialReadiness, DnsblEntry, DnsblEntryKey, EnforcementMode, + LicenseStatus, +""", + ) + replace_exact( + root, + """ let saved = upsert_dnsbl(&mut data.dnsbl, entry.clone()); + record_successful_audit_log( +""", + """ let saved = upsert_dnsbl(&mut data.dnsbl, entry.clone()); + mark_operator_dnsbl_key(data, &saved); + record_successful_audit_log( +""", + ) + replace_exact( + root, + """fn mark_operator_threat_key(data: &mut AppData, indicator: &ThreatIndicator) { + let key = threat_indicator_key(indicator); + if !data.operator_threat_keys.contains(&key) { + data.operator_threat_keys.push(key); + } +} + +async fn apply_threat_feed_import""", + """fn mark_operator_threat_key(data: &mut AppData, indicator: &ThreatIndicator) { + let key = threat_indicator_key(indicator); + if !data.operator_threat_keys.contains(&key) { + data.operator_threat_keys.push(key); + } +} + +fn mark_operator_dnsbl_key(data: &mut AppData, entry: &DnsblEntry) { + let key = dnsbl_entry_key(entry); + if !data.operator_dnsbl_keys.contains(&key) { + data.operator_dnsbl_keys.push(key); + } +} + +async fn apply_threat_feed_import""", + ) + replace_exact( + root, + """ let operator_owned: HashSet<_> = data.operator_threat_keys.iter().cloned().collect(); + let threat_keys: Vec<_> = feed.threats.iter().map(threat_indicator_key).collect(); + let previous_keys: HashSet<_> = replace_threat_feed_ownership( + &mut data.threat_feed_ownership, + feed.feed_id.clone(), + threat_keys, + ) + .into_iter() + .collect(); +""", + """ let operator_owned: HashSet<_> = data.operator_threat_keys.iter().cloned().collect(); + let operator_dnsbl_owned: HashSet<_> = + data.operator_dnsbl_keys.iter().copied().collect(); + let threat_keys: Vec<_> = feed.threats.iter().map(threat_indicator_key).collect(); + let previous_keys: HashSet<_> = replace_threat_feed_ownership( + &mut data.threat_feed_ownership, + feed.feed_id.clone(), + threat_keys, + ) + .into_iter() + .collect(); + let dnsbl_keys: Vec<_> = feed.dnsbl.iter().map(dnsbl_entry_key).collect(); + let previous_dnsbl_keys: HashSet<_> = replace_threat_feed_dnsbl_ownership( + &mut data.threat_feed_ownership, + feed.feed_id.clone(), + dnsbl_keys, + ) + .into_iter() + .collect(); +""", + ) + replace_exact( + root, + """ }); + } + let mut upserted_threats = 0usize; +""", + """ }); + } + if !previous_dnsbl_keys.is_empty() { + let still_dnsbl_owned: HashSet<_> = data + .threat_feed_ownership + .iter() + .filter(|ownership| ownership.feed_id != feed.feed_id) + .flat_map(|ownership| ownership.dnsbl_keys.iter().copied()) + .collect(); + data.dnsbl.retain(|entry| { + let key = dnsbl_entry_key(entry); + !previous_dnsbl_keys.contains(&key) + || still_dnsbl_owned.contains(&key) + || operator_dnsbl_owned.contains(&key) + }); + } + let mut upserted_threats = 0usize; +""", + ) + replace_exact( + root, + """ for entry in feed.dnsbl.iter().cloned() { + upsert_dnsbl(&mut data.dnsbl, entry); + } +""", + """ let mut upserted_dnsbl = 0usize; + for entry in feed.dnsbl.iter().cloned() { + if operator_dnsbl_owned.contains(&dnsbl_entry_key(&entry)) { + continue; + } + upsert_dnsbl(&mut data.dnsbl, entry); + upserted_dnsbl += 1; + } +""", + ) + replace_exact( + root, + """ upserted_threats, + upserted_dnsbl: feed.dnsbl.len(), +""", + """ upserted_threats, + upserted_dnsbl, +""", + ) + replace_exact( + root, + """ operator_threat_keys: Vec::new(), + dnsbl: Vec::new(), +""", + """ operator_threat_keys: Vec::new(), + operator_dnsbl_keys: Vec::new(), + dnsbl: Vec::new(), +""", + expected=2, + ) + replace_exact( + root, + """ #[tokio::test] + async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() { +""", + """ #[test] + fn operator_dnsbl_key_marking_is_idempotent() { + let mut data = AppData::seeded(); + let entry = DnsblEntry { + address: \"203.0.113.245\".parse().unwrap(), + code: \"127.0.0.2\".to_string(), + reason: \"operator\".to_string(), + source: \"operator\".to_string(), + ttl_seconds: 300, + prefix_len: None, + }; + + mark_operator_dnsbl_key(&mut data, &entry); + mark_operator_dnsbl_key(&mut data, &entry); + assert_eq!(data.operator_dnsbl_keys, vec![dnsbl_entry_key(&entry)]); + } + + #[tokio::test] + async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() { +""", + ) + PY + cargo fmt --all + git diff --check + - name: Verify repaired source before push + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Test and lint repaired tree + shell: bash + run: | + set -euo pipefail + cargo test --locked --workspace + cargo clippy --locked --workspace --all-targets -- -D warnings + - name: Commit only the causal repair and remove this workflow + shell: bash + run: | + set -euo pipefail + changed="$(git diff --name-only | sort)" + expected=$'crates/waf-ids-core/src/lib.rs\nsrc/lib.rs' + test "${changed}" = "${expected}" + git rm .github/workflows/wardnet-dnsbl-repair.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add crates/waf-ids-core/src/lib.rs src/lib.rs + git commit -m "fix(security): reconcile DNSBL feed ownership" + git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" + test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" + git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" From dac89ce0d07c4603c26daa570ed38ec57054eaad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 08:14:37 +0900 Subject: [PATCH 27/34] chore: make exact-head DNSBL repair workflow parseable --- .github/workflows/wardnet-dnsbl-repair.yml | 369 +-------------------- 1 file changed, 3 insertions(+), 366 deletions(-) diff --git a/.github/workflows/wardnet-dnsbl-repair.yml b/.github/workflows/wardnet-dnsbl-repair.yml index 0e56953..19a697a 100644 --- a/.github/workflows/wardnet-dnsbl-repair.yml +++ b/.github/workflows/wardnet-dnsbl-repair.yml @@ -33,374 +33,11 @@ jobs: shell: bash run: | set -euo pipefail - python3 - <<'PY' - from pathlib import Path - - def replace_exact(path: str, old: str, new: str, expected: int = 1) -> None: - file = Path(path) - text = file.read_text() - count = text.count(old) - if count != expected: - raise SystemExit(f"{path}: expected {expected} occurrences, found {count}: {old[:80]!r}") - file.write_text(text.replace(old, new)) - - core = "crates/waf-ids-core/src/lib.rs" - root = "src/lib.rs" - - replace_exact( - core, - """ #[serde(default)] - pub operator_threat_keys: Vec, - pub dnsbl: Vec, -""", - """ #[serde(default)] - pub operator_threat_keys: Vec, - /// DNSBL identities independently managed through the operator API. - #[serde(default)] - pub operator_dnsbl_keys: Vec, - pub dnsbl: Vec, -""", - ) - replace_exact( - core, - """ operator_threat_keys: Vec::new(), - dnsbl: vec![DnsblEntry { -""", - """ operator_threat_keys: Vec::new(), - operator_dnsbl_keys: Vec::new(), - dnsbl: vec![DnsblEntry { -""", - ) - replace_exact( - core, - """} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CommercialProfile { -""", - """} - -/// Stable DNSBL ownership identity. It deliberately matches [`upsert_dnsbl`], -/// whose physical row identity is the IP address rather than payload metadata. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(transparent)] -pub struct DnsblEntryKey(pub IpAddr); - -/// Returns the durable ownership identity for a DNSBL entry. -pub fn dnsbl_entry_key(entry: &DnsblEntry) -> DnsblEntryKey { - DnsblEntryKey(entry.address) -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct CommercialProfile { -""", - ) - replace_exact( - core, - """pub struct ThreatFeedOwnership { - pub feed_id: String, - pub threat_keys: Vec, -} -""", - """pub struct ThreatFeedOwnership { - pub feed_id: String, - pub threat_keys: Vec, - /// DNSBL rows currently claimed by this feed snapshot. - #[serde(default)] - pub dnsbl_keys: Vec, -} -""", - ) - replace_exact( - core, - """ ownership.push(ThreatFeedOwnership { - feed_id, - threat_keys, - }); - Vec::new() - } -} - -pub fn record_audit_log""", - """ ownership.push(ThreatFeedOwnership { - feed_id, - threat_keys, - dnsbl_keys: Vec::new(), - }); - Vec::new() - } -} - -/// Replaces one feed's DNSBL snapshot ownership and returns its prior keys. -pub fn replace_threat_feed_dnsbl_ownership( - ownership: &mut Vec, - feed_id: String, - dnsbl_keys: Vec, -) -> Vec { - if let Some(existing) = ownership.iter_mut().find(|item| item.feed_id == feed_id) { - let previous = existing.dnsbl_keys.clone(); - existing.dnsbl_keys = dnsbl_keys; - previous - } else { - ownership.push(ThreatFeedOwnership { - feed_id, - threat_keys: Vec::new(), - dnsbl_keys, - }); - Vec::new() - } -} - -pub fn record_audit_log""", - ) - replace_exact( - core, - """ #[test] - fn records_audit_logs_with_monotonic_ids() { -""", - """ #[test] - fn replaces_threat_feed_dnsbl_ownership_and_returns_previous_keys() { - let first = DnsblEntryKey(\"203.0.113.1\".parse().unwrap()); - let second = DnsblEntryKey(\"203.0.113.2\".parse().unwrap()); - let mut ownership = Vec::new(); - - assert!(replace_threat_feed_dnsbl_ownership( - &mut ownership, - \"feed-a\".to_string(), - vec![first], - ) - .is_empty()); - assert_eq!(ownership[0].dnsbl_keys, vec![first]); - - let previous = replace_threat_feed_dnsbl_ownership( - &mut ownership, - \"feed-a\".to_string(), - vec![second], - ); - assert_eq!(previous, vec![first]); - assert_eq!(ownership[0].dnsbl_keys, vec![second]); - - assert!(replace_threat_feed_dnsbl_ownership( - &mut ownership, - \"feed-b\".to_string(), - vec![first], - ) - .is_empty()); - assert_eq!(ownership.len(), 2); - assert!(ownership[1].threat_keys.is_empty()); - } - - #[test] - fn dnsbl_ownership_fields_default_when_deserializing_predecessor_state() { - let key = DnsblEntryKey(\"203.0.113.3\".parse().unwrap()); - let mut data = AppData::seeded(); - data.operator_dnsbl_keys.push(key); - data.threat_feed_ownership.push(ThreatFeedOwnership { - feed_id: \"legacy-feed\".to_string(), - threat_keys: Vec::new(), - dnsbl_keys: vec![key], - }); - - let mut legacy = serde_json::to_value(data).unwrap(); - let object = legacy.as_object_mut().unwrap(); - object.remove(\"operator_dnsbl_keys\"); - object[\"threat_feed_ownership\"].as_array_mut().unwrap()[0] - .as_object_mut() - .unwrap() - .remove(\"dnsbl_keys\"); - - let loaded: AppData = serde_json::from_value(legacy).unwrap(); - assert!(loaded.operator_dnsbl_keys.is_empty()); - assert!(loaded.threat_feed_ownership[0].dnsbl_keys.is_empty()); - } - - #[test] - fn records_audit_logs_with_monotonic_ids() { -""", - ) - - replace_exact( - root, - """ AppData, BLOCK_SCORE, buyer_evidence_manifest_at, commercial_readiness_snapshot_at, - enforce_event_limit, kpi_snapshot_at, prometheus_exposition, rate_limit_step, record_audit_log, - replace_threat_feed_ownership, select_route, signature_catalog, threat_feed_freshness_snapshot, - threat_indicator_key, upsert_dnsbl, upsert_route, upsert_threat, upsert_threat_feed, -""", - """ AppData, BLOCK_SCORE, buyer_evidence_manifest_at, commercial_readiness_snapshot_at, - dnsbl_entry_key, enforce_event_limit, kpi_snapshot_at, prometheus_exposition, rate_limit_step, - record_audit_log, replace_threat_feed_dnsbl_ownership, replace_threat_feed_ownership, - select_route, signature_catalog, threat_feed_freshness_snapshot, threat_indicator_key, - upsert_dnsbl, upsert_route, upsert_threat, upsert_threat_feed, -""", - ) - replace_exact( - root, - """ CommercialProfile, CommercialReadiness, DnsblEntry, EnforcementMode, LicenseStatus, -""", - """ CommercialProfile, CommercialReadiness, DnsblEntry, DnsblEntryKey, EnforcementMode, - LicenseStatus, -""", - ) - replace_exact( - root, - """ let saved = upsert_dnsbl(&mut data.dnsbl, entry.clone()); - record_successful_audit_log( -""", - """ let saved = upsert_dnsbl(&mut data.dnsbl, entry.clone()); - mark_operator_dnsbl_key(data, &saved); - record_successful_audit_log( -""", - ) - replace_exact( - root, - """fn mark_operator_threat_key(data: &mut AppData, indicator: &ThreatIndicator) { - let key = threat_indicator_key(indicator); - if !data.operator_threat_keys.contains(&key) { - data.operator_threat_keys.push(key); - } -} - -async fn apply_threat_feed_import""", - """fn mark_operator_threat_key(data: &mut AppData, indicator: &ThreatIndicator) { - let key = threat_indicator_key(indicator); - if !data.operator_threat_keys.contains(&key) { - data.operator_threat_keys.push(key); - } -} - -fn mark_operator_dnsbl_key(data: &mut AppData, entry: &DnsblEntry) { - let key = dnsbl_entry_key(entry); - if !data.operator_dnsbl_keys.contains(&key) { - data.operator_dnsbl_keys.push(key); - } -} - -async fn apply_threat_feed_import""", - ) - replace_exact( - root, - """ let operator_owned: HashSet<_> = data.operator_threat_keys.iter().cloned().collect(); - let threat_keys: Vec<_> = feed.threats.iter().map(threat_indicator_key).collect(); - let previous_keys: HashSet<_> = replace_threat_feed_ownership( - &mut data.threat_feed_ownership, - feed.feed_id.clone(), - threat_keys, - ) - .into_iter() - .collect(); -""", - """ let operator_owned: HashSet<_> = data.operator_threat_keys.iter().cloned().collect(); - let operator_dnsbl_owned: HashSet<_> = - data.operator_dnsbl_keys.iter().copied().collect(); - let threat_keys: Vec<_> = feed.threats.iter().map(threat_indicator_key).collect(); - let previous_keys: HashSet<_> = replace_threat_feed_ownership( - &mut data.threat_feed_ownership, - feed.feed_id.clone(), - threat_keys, - ) - .into_iter() - .collect(); - let dnsbl_keys: Vec<_> = feed.dnsbl.iter().map(dnsbl_entry_key).collect(); - let previous_dnsbl_keys: HashSet<_> = replace_threat_feed_dnsbl_ownership( - &mut data.threat_feed_ownership, - feed.feed_id.clone(), - dnsbl_keys, - ) - .into_iter() - .collect(); -""", - ) - replace_exact( - root, - """ }); - } - let mut upserted_threats = 0usize; -""", - """ }); - } - if !previous_dnsbl_keys.is_empty() { - let still_dnsbl_owned: HashSet<_> = data - .threat_feed_ownership - .iter() - .filter(|ownership| ownership.feed_id != feed.feed_id) - .flat_map(|ownership| ownership.dnsbl_keys.iter().copied()) - .collect(); - data.dnsbl.retain(|entry| { - let key = dnsbl_entry_key(entry); - !previous_dnsbl_keys.contains(&key) - || still_dnsbl_owned.contains(&key) - || operator_dnsbl_owned.contains(&key) - }); - } - let mut upserted_threats = 0usize; -""", - ) - replace_exact( - root, - """ for entry in feed.dnsbl.iter().cloned() { - upsert_dnsbl(&mut data.dnsbl, entry); - } -""", - """ let mut upserted_dnsbl = 0usize; - for entry in feed.dnsbl.iter().cloned() { - if operator_dnsbl_owned.contains(&dnsbl_entry_key(&entry)) { - continue; - } - upsert_dnsbl(&mut data.dnsbl, entry); - upserted_dnsbl += 1; - } -""", - ) - replace_exact( - root, - """ upserted_threats, - upserted_dnsbl: feed.dnsbl.len(), -""", - """ upserted_threats, - upserted_dnsbl, -""", - ) - replace_exact( - root, - """ operator_threat_keys: Vec::new(), - dnsbl: Vec::new(), -""", - """ operator_threat_keys: Vec::new(), - operator_dnsbl_keys: Vec::new(), - dnsbl: Vec::new(), -""", - expected=2, - ) - replace_exact( - root, - """ #[tokio::test] - async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() { -""", - """ #[test] - fn operator_dnsbl_key_marking_is_idempotent() { - let mut data = AppData::seeded(); - let entry = DnsblEntry { - address: \"203.0.113.245\".parse().unwrap(), - code: \"127.0.0.2\".to_string(), - reason: \"operator\".to_string(), - source: \"operator\".to_string(), - ttl_seconds: 300, - prefix_len: None, - }; - - mark_operator_dnsbl_key(&mut data, &entry); - mark_operator_dnsbl_key(&mut data, &entry); - assert_eq!(data.operator_dnsbl_keys, vec![dnsbl_entry_key(&entry)]); - } - - #[tokio::test] - async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() { -""", - ) - PY + printf '%s' 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgpkZWYgcmVwbGFjZV9leGFjdChwYXRoOiBzdHIsIG9sZDogc3RyLCBuZXc6IHN0ciwgZXhwZWN0ZWQ6IGludCA9IDEpIC0+IE5vbmU6CiAgICBmaWxlID0gUGF0aChwYXRoKQogICAgdGV4dCA9IGZpbGUucmVhZF90ZXh0KCkKICAgIGNvdW50ID0gdGV4dC5jb3VudChvbGQpCiAgICBpZiBjb3VudCAhPSBleHBlY3RlZDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KGYie3BhdGh9OiBleHBlY3RlZCB7ZXhwZWN0ZWR9IG9jY3VycmVuY2VzLCBmb3VuZCB7Y291bnR9OiB7b2xkWzo4MF0hcn0iKQogICAgZmlsZS53cml0ZV90ZXh0KHRleHQucmVwbGFjZShvbGQsIG5ldykpCgpjb3JlID0gImNyYXRlcy93YWYtaWRzLWNvcmUvc3JjL2xpYi5ycyIKcm9vdCA9ICJzcmMvbGliLnJzIgoKcmVwbGFjZV9leGFjdCgKICAgIGNvcmUsCiAgICAiIiIgICAgI1tzZXJkZShkZWZhdWx0KV0KICAgIHB1YiBvcGVyYXRvcl90aHJlYXRfa2V5czogVmVjPFRocmVhdEluZGljYXRvcktleT4sCiAgICBwdWIgZG5zYmw6IFZlYzxEbnNibEVudHJ5PiwKIiIiLAogICAgIiIiICAgICNbc2VyZGUoZGVmYXVsdCldCiAgICBwdWIgb3BlcmF0b3JfdGhyZWF0X2tleXM6IFZlYzxUaHJlYXRJbmRpY2F0b3JLZXk+LAogICAgLy8vIEROU0JMIGlkZW50aXRpZXMgaW5kZXBlbmRlbnRseSBtYW5hZ2VkIHRocm91Z2ggdGhlIG9wZXJhdG9yIEFQSS4KICAgICNbc2VyZGUoZGVmYXVsdCldCiAgICBwdWIgb3BlcmF0b3JfZG5zYmxfa2V5czogVmVjPERuc2JsRW50cnlLZXk+LAogICAgcHViIGRuc2JsOiBWZWM8RG5zYmxFbnRyeT4sCiIiIiwKKQpyZXBsYWNlX2V4YWN0KAogICAgY29yZSwKICAgICIiIiAgICAgICAgICAgIG9wZXJhdG9yX3RocmVhdF9rZXlzOiBWZWM6Om5ldygpLAogICAgICAgICAgICBkbnNibDogdmVjIVtEbnNibEVudHJ5IHsKIiIiLAogICAgIiIiICAgICAgICAgICAgb3BlcmF0b3JfdGhyZWF0X2tleXM6IFZlYzo6bmV3KCksCiAgICAgICAgICAgIG9wZXJhdG9yX2Ruc2JsX2tleXM6IFZlYzo6bmV3KCksCiAgICAgICAgICAgIGRuc2JsOiB2ZWMhW0Ruc2JsRW50cnkgewoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIGNvcmUsCiAgICAiIiJ9CgojW2Rlcml2ZShEZWJ1ZywgQ2xvbmUsIFNlcmlhbGl6ZSwgRGVzZXJpYWxpemUsIFBhcnRpYWxFcSwgRXEpXQpwdWIgc3RydWN0IENvbW1lcmNpYWxQcm9maWxlIHsKIiIiLAogICAgIiIifQoKLy8vIFN0YWJsZSBETlNCTCBvd25lcnNoaXAgaWRlbnRpdHkuIEl0IGRlbGliZXJhdGVseSBtYXRjaGVzIFtgdXBzZXJ0X2Ruc2JsYF0sCi8vLyB3aG9zZSBwaHlzaWNhbCByb3cgaWRlbnRpdHkgaXMgdGhlIElQIGFkZHJlc3MgcmF0aGVyIHRoYW4gcGF5bG9hZCBtZXRhZGF0YS4KI1tkZXJpdmUoRGVidWcsIENsb25lLCBDb3B5LCBTZXJpYWxpemUsIERlc2VyaWFsaXplLCBQYXJ0aWFsRXEsIEVxLCBIYXNoKV0KI1tzZXJkZSh0cmFuc3BhcmVudCldCnB1YiBzdHJ1Y3QgRG5zYmxFbnRyeUtleShwdWIgSXBBZGRyKTsKCi8vLyBSZXR1cm5zIHRoZSBkdXJhYmxlIG93bmVyc2hpcCBpZGVudGl0eSBmb3IgYSBETlNCTCBlbnRyeS4KcHViIGZuIGRuc2JsX2VudHJ5X2tleShlbnRyeTogJkRuc2JsRW50cnkpIC0+IERuc2JsRW50cnlLZXkgewogICAgRG5zYmxFbnRyeUtleShlbnRyeS5hZGRyZXNzKQp9CgojW2Rlcml2ZShEZWJ1ZywgQ2xvbmUsIFNlcmlhbGl6ZSwgRGVzZXJpYWxpemUsIFBhcnRpYWxFcSwgRXEpXQpwdWIgc3RydWN0IENvbW1lcmNpYWxQcm9maWxlIHsKIiIiLAopCnJlcGxhY2VfZXhhY3QoCiAgICBjb3JlLAogICAgIiIicHViIHN0cnVjdCBUaHJlYXRGZWVkT3duZXJzaGlwIHsKICAgIHB1YiBmZWVkX2lkOiBTdHJpbmcsCiAgICBwdWIgdGhyZWF0X2tleXM6IFZlYzxUaHJlYXRJbmRpY2F0b3JLZXk+LAp9CiIiIiwKICAgICIiInB1YiBzdHJ1Y3QgVGhyZWF0RmVlZE93bmVyc2hpcCB7CiAgICBwdWIgZmVlZF9pZDogU3RyaW5nLAogICAgcHViIHRocmVhdF9rZXlzOiBWZWM8VGhyZWF0SW5kaWNhdG9yS2V5PiwKICAgIC8vLyBETlNCTCByb3dzIGN1cnJlbnRseSBjbGFpbWVkIGJ5IHRoaXMgZmVlZCBzbmFwc2hvdC4KICAgICNbc2VyZGUoZGVmYXVsdCldCiAgICBwdWIgZG5zYmxfa2V5czogVmVjPERuc2JsRW50cnlLZXk+LAp9CiIiIiwKKQpyZXBsYWNlX2V4YWN0KAogICAgY29yZSwKICAgICIiIiAgICAgICAgb3duZXJzaGlwLnB1c2goVGhyZWF0RmVlZE93bmVyc2hpcCB7CiAgICAgICAgICAgIGZlZWRfaWQsCiAgICAgICAgICAgIHRocmVhdF9rZXlzLAogICAgICAgIH0pOwogICAgICAgIFZlYzo6bmV3KCkKICAgIH0KfQoKcHViIGZuIHJlY29yZF9hdWRpdF9sb2ciIiIsCiAgICAiIiIgICAgICAgIG93bmVyc2hpcC5wdXNoKFRocmVhdEZlZWRPd25lcnNoaXAgewogICAgICAgICAgICBmZWVkX2lkLAogICAgICAgICAgICB0aHJlYXRfa2V5cywKICAgICAgICAgICAgZG5zYmxfa2V5czogVmVjOjpuZXcoKSwKICAgICAgICB9KTsKICAgICAgICBWZWM6Om5ldygpCiAgICB9Cn0KCi8vLyBSZXBsYWNlcyBvbmUgZmVlZCdzIEROU0JMIHNuYXBzaG90IG93bmVyc2hpcCBhbmQgcmV0dXJucyBpdHMgcHJpb3Iga2V5cy4KcHViIGZuIHJlcGxhY2VfdGhyZWF0X2ZlZWRfZG5zYmxfb3duZXJzaGlwKAogICAgb3duZXJzaGlwOiAmbXV0IFZlYzxUaHJlYXRGZWVkT3duZXJzaGlwPiwKICAgIGZlZWRfaWQ6IFN0cmluZywKICAgIGRuc2JsX2tleXM6IFZlYzxEbnNibEVudHJ5S2V5PiwKKSAtPiBWZWM8RG5zYmxFbnRyeUtleT4gewogICAgaWYgbGV0IFNvbWUoZXhpc3RpbmcpID0gb3duZXJzaGlwLml0ZXJfbXV0KCkuZmluZCh8aXRlbXwgaXRlbS5mZWVkX2lkID09IGZlZWRfaWQpIHsKICAgICAgICBsZXQgcHJldmlvdXMgPSBleGlzdGluZy5kbnNibF9rZXlzLmNsb25lKCk7CiAgICAgICAgZXhpc3RpbmcuZG5zYmxfa2V5cyA9IGRuc2JsX2tleXM7CiAgICAgICAgcHJldmlvdXMKICAgIH0gZWxzZSB7CiAgICAgICAgb3duZXJzaGlwLnB1c2goVGhyZWF0RmVlZE93bmVyc2hpcCB7CiAgICAgICAgICAgIGZlZWRfaWQsCiAgICAgICAgICAgIHRocmVhdF9rZXlzOiBWZWM6Om5ldygpLAogICAgICAgICAgICBkbnNibF9rZXlzLAogICAgICAgIH0pOwogICAgICAgIFZlYzo6bmV3KCkKICAgIH0KfQoKcHViIGZuIHJlY29yZF9hdWRpdF9sb2ciIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIGNvcmUsCiAgICAiIiIgICAgI1t0ZXN0XQogICAgZm4gcmVjb3Jkc19hdWRpdF9sb2dzX3dpdGhfbW9ub3RvbmljX2lkcygpIHsKIiIiLAogICAgIiIiICAgICNbdGVzdF0KICAgIGZuIHJlcGxhY2VzX3RocmVhdF9mZWVkX2Ruc2JsX293bmVyc2hpcF9hbmRfcmV0dXJuc19wcmV2aW91c19rZXlzKCkgewogICAgICAgIGxldCBmaXJzdCA9IERuc2JsRW50cnlLZXkoIjIwMy4wLjExMy4xIi5wYXJzZSgpLnVud3JhcCgpKTsKICAgICAgICBsZXQgc2Vjb25kID0gRG5zYmxFbnRyeUtleSgiMjAzLjAuMTEzLjIiLnBhcnNlKCkudW53cmFwKCkpOwogICAgICAgIGxldCBtdXQgb3duZXJzaGlwID0gVmVjOjpuZXcoKTsKCiAgICAgICAgYXNzZXJ0IShyZXBsYWNlX3RocmVhdF9mZWVkX2Ruc2JsX293bmVyc2hpcCgKICAgICAgICAgICAgJm11dCBvd25lcnNoaXAsCiAgICAgICAgICAgICJmZWVkLWEiLnRvX3N0cmluZygpLAogICAgICAgICAgICB2ZWMhW2ZpcnN0XSwKICAgICAgICApCiAgICAgICAgLmlzX2VtcHR5KCkpOwogICAgICAgIGFzc2VydF9lcSEob3duZXJzaGlwWzBdLmRuc2JsX2tleXMsIHZlYyFbZmlyc3RdKTsKCiAgICAgICAgbGV0IHByZXZpb3VzID0gcmVwbGFjZV90aHJlYXRfZmVlZF9kbnNibF9vd25lcnNoaXAoCiAgICAgICAgICAgICZtdXQgb3duZXJzaGlwLAogICAgICAgICAgICAiZmVlZC1hIi50b19zdHJpbmcoKSwKICAgICAgICAgICAgdmVjIVtzZWNvbmRdLAogICAgICAgICk7CiAgICAgICAgYXNzZXJ0X2VxIShwcmV2aW91cywgdmVjIVtmaXJzdF0pOwogICAgICAgIGFzc2VydF9lcSEob3duZXJzaGlwWzBdLmRuc2JsX2tleXMsIHZlYyFbc2Vjb25kXSk7CgogICAgICAgIGFzc2VydCEocmVwbGFjZV90aHJlYXRfZmVlZF9kbnNibF9vd25lcnNoaXAoCiAgICAgICAgICAgICZtdXQgb3duZXJzaGlwLAogICAgICAgICAgICAiZmVlZC1iIi50b19zdHJpbmcoKSwKICAgICAgICAgICAgdmVjIVtmaXJzdF0sCiAgICAgICAgKQogICAgICAgIC5pc19lbXB0eSgpKTsKICAgICAgICBhc3NlcnRfZXEhKG93bmVyc2hpcC5sZW4oKSwgMik7CiAgICAgICAgYXNzZXJ0IShvd25lcnNoaXBbMV0udGhyZWF0X2tleXMuaXNfZW1wdHkoKSk7CiAgICB9CgogICAgI1t0ZXN0XQogICAgZm4gZG5zYmxfb3duZXJzaGlwX2ZpZWxkc19kZWZhdWx0X3doZW5fZGVzZXJpYWxpemluZ19wcmVkZWNlc3Nvcl9zdGF0ZSgpIHsKICAgICAgICBsZXQga2V5ID0gRG5zYmxFbnRyeUtleSgiMjAzLjAuMTEzLjMiLnBhcnNlKCkudW53cmFwKCkpOwogICAgICAgIGxldCBtdXQgZGF0YSA9IEFwcERhdGE6OnNlZWRlZCgpOwogICAgICAgIGRhdGEub3BlcmF0b3JfZG5zYmxfa2V5cy5wdXNoKGtleSk7CiAgICAgICAgZGF0YS50aHJlYXRfZmVlZF9vd25lcnNoaXAucHVzaChUaHJlYXRGZWVkT3duZXJzaGlwIHsKICAgICAgICAgICAgZmVlZF9pZDogImxlZ2FjeS1mZWVkIi50b19zdHJpbmcoKSwKICAgICAgICAgICAgdGhyZWF0X2tleXM6IFZlYzo6bmV3KCksCiAgICAgICAgICAgIGRuc2JsX2tleXM6IHZlYyFba2V5XSwKICAgICAgICB9KTsKCiAgICAgICAgbGV0IG11dCBsZWdhY3kgPSBzZXJkZV9qc29uOjp0b192YWx1ZShkYXRhKS51bndyYXAoKTsKICAgICAgICBsZXQgb2JqZWN0ID0gbGVnYWN5LmFzX29iamVjdF9tdXQoKS51bndyYXAoKTsKICAgICAgICBvYmplY3QucmVtb3ZlKCJvcGVyYXRvcl9kbnNibF9rZXlzIik7CiAgICAgICAgb2JqZWN0WyJ0aHJlYXRfZmVlZF9vd25lcnNoaXAiXS5hc19hcnJheV9tdXQoKS51bndyYXAoKVswXQogICAgICAgICAgICAuYXNfb2JqZWN0X211dCgpCiAgICAgICAgICAgIC51bndyYXAoKQogICAgICAgICAgICAucmVtb3ZlKCJkbnNibF9rZXlzIik7CgogICAgICAgIGxldCBsb2FkZWQ6IEFwcERhdGEgPSBzZXJkZV9qc29uOjpmcm9tX3ZhbHVlKGxlZ2FjeSkudW53cmFwKCk7CiAgICAgICAgYXNzZXJ0IShsb2FkZWQub3BlcmF0b3JfZG5zYmxfa2V5cy5pc19lbXB0eSgpKTsKICAgICAgICBhc3NlcnQhKGxvYWRlZC50aHJlYXRfZmVlZF9vd25lcnNoaXBbMF0uZG5zYmxfa2V5cy5pc19lbXB0eSgpKTsKICAgIH0KCiAgICAjW3Rlc3RdCiAgICBmbiByZWNvcmRzX2F1ZGl0X2xvZ3Nfd2l0aF9tb25vdG9uaWNfaWRzKCkgewoiIiIsCikKCnJlcGxhY2VfZXhhY3QoCiAgICByb290LAogICAgIiIiICAgIEFwcERhdGEsIEJMT0NLX1NDT1JFLCBidXllcl9ldmlkZW5jZV9tYW5pZmVzdF9hdCwgY29tbWVyY2lhbF9yZWFkaW5lc3Nfc25hcHNob3RfYXQsCiAgICBlbmZvcmNlX2V2ZW50X2xpbWl0LCBrcGlfc25hcHNob3RfYXQsIHByb21ldGhldXNfZXhwb3NpdGlvbiwgcmF0ZV9saW1pdF9zdGVwLCByZWNvcmRfYXVkaXRfbG9nLAogICAgcmVwbGFjZV90aHJlYXRfZmVlZF9vd25lcnNoaXAsIHNlbGVjdF9yb3V0ZSwgc2lnbmF0dXJlX2NhdGFsb2csIHRocmVhdF9mZWVkX2ZyZXNobmVzc19zbmFwc2hvdCwKICAgIHRocmVhdF9pbmRpY2F0b3Jfa2V5LCB1cHNlcnRfZG5zYmwsIHVwc2VydF9yb3V0ZSwgdXBzZXJ0X3RocmVhdCwgdXBzZXJ0X3RocmVhdF9mZWVkLAoiIiIsCiAgICAiIiIgICAgQXBwRGF0YSwgQkxPQ0tfU0NPUkUsIGJ1eWVyX2V2aWRlbmNlX21hbmlmZXN0X2F0LCBjb21tZXJjaWFsX3JlYWRpbmVzc19zbmFwc2hvdF9hdCwKICAgIGRuc2JsX2VudHJ5X2tleSwgZW5mb3JjZV9ldmVudF9saW1pdCwga3BpX3NuYXBzaG90X2F0LCBwcm9tZXRoZXVzX2V4cG9zaXRpb24sIHJhdGVfbGltaXRfc3RlcCwKICAgIHJlY29yZF9hdWRpdF9sb2csIHJlcGxhY2VfdGhyZWF0X2ZlZWRfZG5zYmxfb3duZXJzaGlwLCByZXBsYWNlX3RocmVhdF9mZWVkX293bmVyc2hpcCwKICAgIHNlbGVjdF9yb3V0ZSwgc2lnbmF0dXJlX2NhdGFsb2csIHRocmVhdF9mZWVkX2ZyZXNobmVzc19zbmFwc2hvdCwgdGhyZWF0X2luZGljYXRvcl9rZXksCiAgICB1cHNlcnRfZG5zYmwsIHVwc2VydF9yb3V0ZSwgdXBzZXJ0X3RocmVhdCwgdXBzZXJ0X3RocmVhdF9mZWVkLAoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgQ29tbWVyY2lhbFByb2ZpbGUsIENvbW1lcmNpYWxSZWFkaW5lc3MsIERuc2JsRW50cnksIEVuZm9yY2VtZW50TW9kZSwgTGljZW5zZVN0YXR1cywKIiIiLAogICAgIiIiICAgIENvbW1lcmNpYWxQcm9maWxlLCBDb21tZXJjaWFsUmVhZGluZXNzLCBEbnNibEVudHJ5LCBEbnNibEVudHJ5S2V5LCBFbmZvcmNlbWVudE1vZGUsCiAgICBMaWNlbnNlU3RhdHVzLAoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgICAgICAgICBsZXQgc2F2ZWQgPSB1cHNlcnRfZG5zYmwoJm11dCBkYXRhLmRuc2JsLCBlbnRyeS5jbG9uZSgpKTsKICAgICAgICAgICAgcmVjb3JkX3N1Y2Nlc3NmdWxfYXVkaXRfbG9nKAoiIiIsCiAgICAiIiIgICAgICAgICAgICBsZXQgc2F2ZWQgPSB1cHNlcnRfZG5zYmwoJm11dCBkYXRhLmRuc2JsLCBlbnRyeS5jbG9uZSgpKTsKICAgICAgICAgICAgbWFya19vcGVyYXRvcl9kbnNibF9rZXkoZGF0YSwgJnNhdmVkKTsKICAgICAgICAgICAgcmVjb3JkX3N1Y2Nlc3NmdWxfYXVkaXRfbG9nKAoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiJmbiBtYXJrX29wZXJhdG9yX3RocmVhdF9rZXkoZGF0YTogJm11dCBBcHBEYXRhLCBpbmRpY2F0b3I6ICZUaHJlYXRJbmRpY2F0b3IpIHsKICAgIGxldCBrZXkgPSB0aHJlYXRfaW5kaWNhdG9yX2tleShpbmRpY2F0b3IpOwogICAgaWYgIWRhdGEub3BlcmF0b3JfdGhyZWF0X2tleXMuY29udGFpbnMoJmtleSkgewogICAgICAgIGRhdGEub3BlcmF0b3JfdGhyZWF0X2tleXMucHVzaChrZXkpOwogICAgfQp9Cgphc3luYyBmbiBhcHBseV90aHJlYXRfZmVlZF9pbXBvcnQiIiIsCiAgICAiIiJmbiBtYXJrX29wZXJhdG9yX3RocmVhdF9rZXkoZGF0YTogJm11dCBBcHBEYXRhLCBpbmRpY2F0b3I6ICZUaHJlYXRJbmRpY2F0b3IpIHsKICAgIGxldCBrZXkgPSB0aHJlYXRfaW5kaWNhdG9yX2tleShpbmRpY2F0b3IpOwogICAgaWYgIWRhdGEub3BlcmF0b3JfdGhyZWF0X2tleXMuY29udGFpbnMoJmtleSkgewogICAgICAgIGRhdGEub3BlcmF0b3JfdGhyZWF0X2tleXMucHVzaChrZXkpOwogICAgfQp9CgpmbiBtYXJrX29wZXJhdG9yX2Ruc2JsX2tleShkYXRhOiAmbXV0IEFwcERhdGEsIGVudHJ5OiAmRG5zYmxFbnRyeSkgewogICAgbGV0IGtleSA9IGRuc2JsX2VudHJ5X2tleShlbnRyeSk7CiAgICBpZiAhZGF0YS5vcGVyYXRvcl9kbnNibF9rZXlzLmNvbnRhaW5zKCZrZXkpIHsKICAgICAgICBkYXRhLm9wZXJhdG9yX2Ruc2JsX2tleXMucHVzaChrZXkpOwogICAgfQp9Cgphc3luYyBmbiBhcHBseV90aHJlYXRfZmVlZF9pbXBvcnQiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgICAgICAgICBsZXQgb3BlcmF0b3Jfb3duZWQ6IEhhc2hTZXQ8Xz4gPSBkYXRhLm9wZXJhdG9yX3RocmVhdF9rZXlzLml0ZXIoKS5jbG9uZWQoKS5jb2xsZWN0KCk7CiAgICAgICAgICAgIGxldCB0aHJlYXRfa2V5czogVmVjPF8+ID0gZmVlZC50aHJlYXRzLml0ZXIoKS5tYXAodGhyZWF0X2luZGljYXRvcl9rZXkpLmNvbGxlY3QoKTsKICAgICAgICAgICAgbGV0IHByZXZpb3VzX2tleXM6IEhhc2hTZXQ8Xz4gPSByZXBsYWNlX3RocmVhdF9mZWVkX293bmVyc2hpcCgKICAgICAgICAgICAgICAgICZtdXQgZGF0YS50aHJlYXRfZmVlZF9vd25lcnNoaXAsCiAgICAgICAgICAgICAgICBmZWVkLmZlZWRfaWQuY2xvbmUoKSwKICAgICAgICAgICAgICAgIHRocmVhdF9rZXlzLAogICAgICAgICAgICApCiAgICAgICAgICAgIC5pbnRvX2l0ZXIoKQogICAgICAgICAgICAuY29sbGVjdCgpOwoiIiIsCiAgICAiIiIgICAgICAgICAgICBsZXQgb3BlcmF0b3Jfb3duZWQ6IEhhc2hTZXQ8Xz4gPSBkYXRhLm9wZXJhdG9yX3RocmVhdF9rZXlzLml0ZXIoKS5jbG9uZWQoKS5jb2xsZWN0KCk7CiAgICAgICAgICAgIGxldCBvcGVyYXRvcl9kbnNibF9vd25lZDogSGFzaFNldDxfPiA9CiAgICAgICAgICAgICAgICBkYXRhLm9wZXJhdG9yX2Ruc2JsX2tleXMuaXRlcigpLmNvcGllZCgpLmNvbGxlY3QoKTsKICAgICAgICAgICAgbGV0IHRocmVhdF9rZXlzOiBWZWM8Xz4gPSBmZWVkLnRocmVhdHMuaXRlcigpLm1hcCh0aHJlYXRfaW5kaWNhdG9yX2tleSkuY29sbGVjdCgpOwogICAgICAgICAgICBsZXQgcHJldmlvdXNfa2V5czogSGFzaFNldDxfPiA9IHJlcGxhY2VfdGhyZWF0X2ZlZWRfb3duZXJzaGlwKAogICAgICAgICAgICAgICAgJm11dCBkYXRhLnRocmVhdF9mZWVkX293bmVyc2hpcCwKICAgICAgICAgICAgICAgIGZlZWQuZmVlZF9pZC5jbG9uZSgpLAogICAgICAgICAgICAgICAgdGhyZWF0X2tleXMsCiAgICAgICAgICAgICkKICAgICAgICAgICAgLmludG9faXRlcigpCiAgICAgICAgICAgIC5jb2xsZWN0KCk7CiAgICAgICAgICAgIGxldCBkbnNibF9rZXlzOiBWZWM8Xz4gPSBmZWVkLmRuc2JsLml0ZXIoKS5tYXAoZG5zYmxfZW50cnlfa2V5KS5jb2xsZWN0KCk7CiAgICAgICAgICAgIGxldCBwcmV2aW91c19kbnNibF9rZXlzOiBIYXNoU2V0PF8+ID0gcmVwbGFjZV90aHJlYXRfZmVlZF9kbnNibF9vd25lcnNoaXAoCiAgICAgICAgICAgICAgICAmbXV0IGRhdGEudGhyZWF0X2ZlZWRfb3duZXJzaGlwLAogICAgICAgICAgICAgICAgZmVlZC5mZWVkX2lkLmNsb25lKCksCiAgICAgICAgICAgICAgICBkbnNibF9rZXlzLAogICAgICAgICAgICApCiAgICAgICAgICAgIC5pbnRvX2l0ZXIoKQogICAgICAgICAgICAuY29sbGVjdCgpOwoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgICAgICAgICAgICAgfSk7CiAgICAgICAgICAgIH0KICAgICAgICAgICAgbGV0IG11dCB1cHNlcnRlZF90aHJlYXRzID0gMHVzaXplOwoiIiIsCiAgICAiIiIgICAgICAgICAgICAgICAgfSk7CiAgICAgICAgICAgIH0KICAgICAgICAgICAgaWYgIXByZXZpb3VzX2Ruc2JsX2tleXMuaXNfZW1wdHkoKSB7CiAgICAgICAgICAgICAgICBsZXQgc3RpbGxfZG5zYmxfb3duZWQ6IEhhc2hTZXQ8Xz4gPSBkYXRhCiAgICAgICAgICAgICAgICAgICAgLnRocmVhdF9mZWVkX293bmVyc2hpcAogICAgICAgICAgICAgICAgICAgIC5pdGVyKCkKICAgICAgICAgICAgICAgICAgICAuZmlsdGVyKHxvd25lcnNoaXB8IG93bmVyc2hpcC5mZWVkX2lkICE9IGZlZWQuZmVlZF9pZCkKICAgICAgICAgICAgICAgICAgICAuZmxhdF9tYXAofG93bmVyc2hpcHwgb3duZXJzaGlwLmRuc2JsX2tleXMuaXRlcigpLmNvcGllZCgpKQogICAgICAgICAgICAgICAgICAgIC5jb2xsZWN0KCk7CiAgICAgICAgICAgICAgICBkYXRhLmRuc2JsLnJldGFpbih8ZW50cnl8IHsKICAgICAgICAgICAgICAgICAgICBsZXQga2V5ID0gZG5zYmxfZW50cnlfa2V5KGVudHJ5KTsKICAgICAgICAgICAgICAgICAgICAhcHJldmlvdXNfZG5zYmxfa2V5cy5jb250YWlucygma2V5KQogICAgICAgICAgICAgICAgICAgICAgICB8fCBzdGlsbF9kbnNibF9vd25lZC5jb250YWlucygma2V5KQogICAgICAgICAgICAgICAgICAgICAgICB8fCBvcGVyYXRvcl9kbnNibF9vd25lZC5jb250YWlucygma2V5KQogICAgICAgICAgICAgICAgfSk7CiAgICAgICAgICAgIH0KICAgICAgICAgICAgbGV0IG11dCB1cHNlcnRlZF90aHJlYXRzID0gMHVzaXplOwoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgICAgICAgICBmb3IgZW50cnkgaW4gZmVlZC5kbnNibC5pdGVyKCkuY2xvbmVkKCkgewogICAgICAgICAgICAgICAgdXBzZXJ0X2Ruc2JsKCZtdXQgZGF0YS5kbnNibCwgZW50cnkpOwogICAgICAgICAgICB9CiIiIiwKICAgICIiIiAgICAgICAgICAgIGxldCBtdXQgdXBzZXJ0ZWRfZG5zYmwgPSAwdXNpemU7CiAgICAgICAgICAgIGZvciBlbnRyeSBpbiBmZWVkLmRuc2JsLml0ZXIoKS5jbG9uZWQoKSB7CiAgICAgICAgICAgICAgICBpZiBvcGVyYXRvcl9kbnNibF9vd25lZC5jb250YWlucygmZG5zYmxfZW50cnlfa2V5KCZlbnRyeSkpIHsKICAgICAgICAgICAgICAgICAgICBjb250aW51ZTsKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIHVwc2VydF9kbnNibCgmbXV0IGRhdGEuZG5zYmwsIGVudHJ5KTsKICAgICAgICAgICAgICAgIHVwc2VydGVkX2Ruc2JsICs9IDE7CiAgICAgICAgICAgIH0KIiIiLAopCnJlcGxhY2VfZXhhY3QoCiAgICByb290LAogICAgIiIiICAgICAgICAgICAgICAgIHVwc2VydGVkX3RocmVhdHMsCiAgICAgICAgICAgICAgICB1cHNlcnRlZF9kbnNibDogZmVlZC5kbnNibC5sZW4oKSwKIiIiLAogICAgIiIiICAgICAgICAgICAgICAgIHVwc2VydGVkX3RocmVhdHMsCiAgICAgICAgICAgICAgICB1cHNlcnRlZF9kbnNibCwKIiIiLAopCnJlcGxhY2VfZXhhY3QoCiAgICByb290LAogICAgIiIiICAgICAgICAgICAgICAgIG9wZXJhdG9yX3RocmVhdF9rZXlzOiBWZWM6Om5ldygpLAogICAgICAgICAgICAgICAgZG5zYmw6IFZlYzo6bmV3KCksCiIiLAogICAgIiIiICAgICAgICAgICAgICAgIG9wZXJhdG9yX3RocmVhdF9rZXlzOiBWZWM6Om5ldygpLAogICAgICAgICAgICAgICAgb3BlcmF0b3JfZG5zYmxfa2V5czogVmVjOjpuZXcoKSwKICAgICAgICAgICAgICAgIGRuc2JsOiBWZWM6Om5ldygpLAoiIiIsCiAgICBleHBlY3RlZD0yLAopCnJlcGxhY2VfZXhhY3QoCiAgICByb290LAogICAgIiIiICAgICNbdG9raW86OnRlc3RdCiAgICBhc3luYyBmbiBnYXRld2F5X2NvdmVyc19tb25pdG9yX3Byb3h5X25vdF9mb3VuZF9hbmRfYmFkX2dhdGV3YXlfcGF0aHMoKSB7CiIiLAogICAgIiIiICAgICNbdGVzdF0KICAgIGZuIG9wZXJhdG9yX2Ruc2JsX2tleV9tYXJraW5nX2lzX2lkZW1wb3RlbnQoKSB7CiAgICAgICAgbGV0IG11dCBkYXRhID0gQXBwRGF0YTo6c2VlZGVkKCk7CiAgICAgICAgbGV0IGVudHJ5ID0gRG5zYmxFbnRyeSB7CiAgICAgICAgICAgIGFkZHJlc3M6ICIyMDMuMC4xMTMuMjQ1Ii5wYXJzZSgpLnVud3JhcCgpLAogICAgICAgICAgICBjb2RlOiAiMTI3LjAuMC4yIi50b19zdHJpbmcoKSwKICAgICAgICAgICAgcmVhc29uOiAib3BlcmF0b3IiLnRvX3N0cmluZygpLAogICAgICAgICAgICBzb3VyY2U6ICJvcGVyYXRvciIudG9fc3RyaW5nKCksCiAgICAgICAgICAgIHR0bF9zZWNvbmRzOiAzMDAsCiAgICAgICAgICAgIHByZWZpeF9sZW46IE5vbmUsCiAgICAgICAgfTsKCiAgICAgICAgbWFya19vcGVyYXRvcl9kbnNibF9rZXkoJm11dCBkYXRhLCAmZW50cnkpOwogICAgICAgIG1hcmtfb3BlcmF0b3JfZG5zYmxfa2V5KCZtdXQgZGF0YSwgJmVudHJ5KTsKICAgICAgICBhc3NlcnRfZXEhKGRhdGEub3BlcmF0b3JfZG5zYmxfa2V5cywgdmVjIVtkbnNibF9lbnRyeV9rZXkoJmVudHJ5KV0pOwogICAgfQoKICAgICNbdG9raW86OnRlc3RdCiAgICBhc3luYyBmbiBnYXRld2F5X2NvdmVyc19tb25pdG9yX3Byb3h5X25vdF9mb3VuZF9hbmRfYmFkX2dhdGV3YXlfcGF0aHMoKSB7CiIiLAopCg==' | base64 -d >/tmp/wardnet-dnsbl-repair.py + python3 /tmp/wardnet-dnsbl-repair.py cargo fmt --all git diff --check - - name: Verify repaired source before push + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: toolchain: stable From 218d3b97bdcc82ecab27e74565c57ebeee926bcd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:07:49 +0900 Subject: [PATCH 28/34] chore: fail over DNSBL repair to macOS runner --- .../workflows/wardnet-dnsbl-repair-macos.yml | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .github/workflows/wardnet-dnsbl-repair-macos.yml diff --git a/.github/workflows/wardnet-dnsbl-repair-macos.yml b/.github/workflows/wardnet-dnsbl-repair-macos.yml new file mode 100644 index 0000000..b20d0e2 --- /dev/null +++ b/.github/workflows/wardnet-dnsbl-repair-macos.yml @@ -0,0 +1,79 @@ +name: Wardnet DNSBL ownership repair (macOS rescue temporary) + +on: + push: + branches: + - fix/misp-to-ids-fail-closed + paths: + - .github/workflows/wardnet-dnsbl-repair-macos.yml + +permissions: + contents: write + +concurrency: + group: wardnet-dnsbl-repair-macos-${{ github.ref }} + cancel-in-progress: false + +jobs: + repair: + runs-on: macos-15 + steps: + - name: Checkout triggering head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Refuse stale-parent execution + shell: bash + run: | + set -euo pipefail + git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" + test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" + - name: Reuse the reviewed exact-count repair payload + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import base64 + import re + from pathlib import Path + + source = Path('.github/workflows/wardnet-dnsbl-repair.yml').read_text() + match = re.search( + r"printf '%s' '([A-Za-z0-9+/=]+)' \\| base64 -d >/tmp/wardnet-dnsbl-repair\\.py", + source, + ) + if match is None: + raise SystemExit('reviewed DNSBL repair payload not found exactly once') + if len(re.findall(r"printf '%s' '[A-Za-z0-9+/=]+' \\| base64 -d >/tmp/wardnet-dnsbl-repair\\.py", source)) != 1: + raise SystemExit('reviewed DNSBL repair payload is ambiguous') + Path('/tmp/wardnet-dnsbl-repair.py').write_bytes(base64.b64decode(match.group(1), validate=True)) + PY + python3 /tmp/wardnet-dnsbl-repair.py + cargo fmt --all + git diff --check + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Test and lint repaired tree + shell: bash + run: | + set -euo pipefail + cargo test --locked --workspace + cargo clippy --locked --workspace --all-targets -- -D warnings + - name: Commit only the causal repair and remove temporary workflows + shell: bash + run: | + set -euo pipefail + changed="$(git diff --name-only | sort)" + expected=$'crates/waf-ids-core/src/lib.rs\nsrc/lib.rs' + test "${changed}" = "${expected}" + git rm .github/workflows/wardnet-dnsbl-repair.yml .github/workflows/wardnet-dnsbl-repair-macos.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add crates/waf-ids-core/src/lib.rs src/lib.rs + git commit -m "fix(security): reconcile DNSBL feed ownership" + git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" + test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" + git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" From 6727224000bae6545e1120e46f5f5cb26d237fc4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:09:03 +0900 Subject: [PATCH 29/34] chore: fix macOS DNSBL repair payload extraction --- .github/workflows/wardnet-dnsbl-repair-macos.yml | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/wardnet-dnsbl-repair-macos.yml b/.github/workflows/wardnet-dnsbl-repair-macos.yml index b20d0e2..be97127 100644 --- a/.github/workflows/wardnet-dnsbl-repair-macos.yml +++ b/.github/workflows/wardnet-dnsbl-repair-macos.yml @@ -39,15 +39,11 @@ jobs: from pathlib import Path source = Path('.github/workflows/wardnet-dnsbl-repair.yml').read_text() - match = re.search( - r"printf '%s' '([A-Za-z0-9+/=]+)' \\| base64 -d >/tmp/wardnet-dnsbl-repair\\.py", - source, - ) - if match is None: - raise SystemExit('reviewed DNSBL repair payload not found exactly once') - if len(re.findall(r"printf '%s' '[A-Za-z0-9+/=]+' \\| base64 -d >/tmp/wardnet-dnsbl-repair\\.py", source)) != 1: - raise SystemExit('reviewed DNSBL repair payload is ambiguous') - Path('/tmp/wardnet-dnsbl-repair.py').write_bytes(base64.b64decode(match.group(1), validate=True)) + pattern = r"printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d >/tmp/wardnet-dnsbl-repair\.py" + matches = re.findall(pattern, source) + if len(matches) != 1: + raise SystemExit(f'reviewed DNSBL repair payload count={len(matches)}, expected 1') + Path('/tmp/wardnet-dnsbl-repair.py').write_bytes(base64.b64decode(matches[0], validate=True)) PY python3 /tmp/wardnet-dnsbl-repair.py cargo fmt --all From f8410caedfff3c347821c0b41e068c9dcef1f4ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:11:02 +0900 Subject: [PATCH 30/34] chore: expose DNSBL repair script syntax defect --- .github/workflows/wardnet-dnsbl-repair-macos.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wardnet-dnsbl-repair-macos.yml b/.github/workflows/wardnet-dnsbl-repair-macos.yml index be97127..42689e9 100644 --- a/.github/workflows/wardnet-dnsbl-repair-macos.yml +++ b/.github/workflows/wardnet-dnsbl-repair-macos.yml @@ -29,7 +29,7 @@ jobs: set -euo pipefail git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" - - name: Reuse the reviewed exact-count repair payload + - name: Decode and validate the reviewed exact-count repair payload shell: bash run: | set -euo pipefail @@ -45,6 +45,8 @@ jobs: raise SystemExit(f'reviewed DNSBL repair payload count={len(matches)}, expected 1') Path('/tmp/wardnet-dnsbl-repair.py').write_bytes(base64.b64decode(matches[0], validate=True)) PY + nl -ba /tmp/wardnet-dnsbl-repair.py | sed -n '315,370p' + python3 -m py_compile /tmp/wardnet-dnsbl-repair.py python3 /tmp/wardnet-dnsbl-repair.py cargo fmt --all git diff --check From 3d4f01836999e63373bf367936c9a5fb57550120 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:12:00 +0900 Subject: [PATCH 31/34] chore: repair DNSBL patch-script quote delimiters --- .../workflows/wardnet-dnsbl-repair-macos.yml | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wardnet-dnsbl-repair-macos.yml b/.github/workflows/wardnet-dnsbl-repair-macos.yml index 42689e9..7f893a7 100644 --- a/.github/workflows/wardnet-dnsbl-repair-macos.yml +++ b/.github/workflows/wardnet-dnsbl-repair-macos.yml @@ -29,7 +29,7 @@ jobs: set -euo pipefail git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" - - name: Decode and validate the reviewed exact-count repair payload + - name: Decode and repair the reviewed exact-count patch script shell: bash run: | set -euo pipefail @@ -38,14 +38,33 @@ jobs: import re from pathlib import Path - source = Path('.github/workflows/wardnet-dnsbl-repair.yml').read_text() + workflow = Path('.github/workflows/wardnet-dnsbl-repair.yml').read_text() pattern = r"printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d >/tmp/wardnet-dnsbl-repair\.py" - matches = re.findall(pattern, source) + matches = re.findall(pattern, workflow) if len(matches) != 1: raise SystemExit(f'reviewed DNSBL repair payload count={len(matches)}, expected 1') - Path('/tmp/wardnet-dnsbl-repair.py').write_bytes(base64.b64decode(matches[0], validate=True)) + script = base64.b64decode(matches[0], validate=True).decode() + fixes = ( + ( + ' dnsbl: Vec::new(),\n"",\n """ operator_threat_keys:', + ' dnsbl: Vec::new(),\n""",\n """ operator_threat_keys:', + ), + ( + ' async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {\n"",\n """ #[test]', + ' async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {\n""",\n """ #[test]', + ), + ( + ' async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {\n"",\n)', + ' async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {\n""",\n)', + ), + ) + for old, new in fixes: + count = script.count(old) + if count != 1: + raise SystemExit(f'patch-script quote repair expected 1 occurrence, found {count}: {old!r}') + script = script.replace(old, new) + Path('/tmp/wardnet-dnsbl-repair.py').write_text(script) PY - nl -ba /tmp/wardnet-dnsbl-repair.py | sed -n '315,370p' python3 -m py_compile /tmp/wardnet-dnsbl-repair.py python3 /tmp/wardnet-dnsbl-repair.py cargo fmt --all From 7042aa19267886e3af9c378dddd879929837877b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:13:46 +0000 Subject: [PATCH 32/34] fix(security): reconcile DNSBL feed ownership --- .../workflows/wardnet-dnsbl-repair-macos.yml | 96 ------------------- .github/workflows/wardnet-dnsbl-repair.yml | 64 ------------- crates/waf-ids-core/src/lib.rs | 88 +++++++++++++++++ src/lib.rs | 74 ++++++++++++-- 4 files changed, 153 insertions(+), 169 deletions(-) delete mode 100644 .github/workflows/wardnet-dnsbl-repair-macos.yml delete mode 100644 .github/workflows/wardnet-dnsbl-repair.yml diff --git a/.github/workflows/wardnet-dnsbl-repair-macos.yml b/.github/workflows/wardnet-dnsbl-repair-macos.yml deleted file mode 100644 index 7f893a7..0000000 --- a/.github/workflows/wardnet-dnsbl-repair-macos.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: Wardnet DNSBL ownership repair (macOS rescue temporary) - -on: - push: - branches: - - fix/misp-to-ids-fail-closed - paths: - - .github/workflows/wardnet-dnsbl-repair-macos.yml - -permissions: - contents: write - -concurrency: - group: wardnet-dnsbl-repair-macos-${{ github.ref }} - cancel-in-progress: false - -jobs: - repair: - runs-on: macos-15 - steps: - - name: Checkout triggering head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - - name: Refuse stale-parent execution - shell: bash - run: | - set -euo pipefail - git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" - test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" - - name: Decode and repair the reviewed exact-count patch script - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - import base64 - import re - from pathlib import Path - - workflow = Path('.github/workflows/wardnet-dnsbl-repair.yml').read_text() - pattern = r"printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d >/tmp/wardnet-dnsbl-repair\.py" - matches = re.findall(pattern, workflow) - if len(matches) != 1: - raise SystemExit(f'reviewed DNSBL repair payload count={len(matches)}, expected 1') - script = base64.b64decode(matches[0], validate=True).decode() - fixes = ( - ( - ' dnsbl: Vec::new(),\n"",\n """ operator_threat_keys:', - ' dnsbl: Vec::new(),\n""",\n """ operator_threat_keys:', - ), - ( - ' async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {\n"",\n """ #[test]', - ' async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {\n""",\n """ #[test]', - ), - ( - ' async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {\n"",\n)', - ' async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() {\n""",\n)', - ), - ) - for old, new in fixes: - count = script.count(old) - if count != 1: - raise SystemExit(f'patch-script quote repair expected 1 occurrence, found {count}: {old!r}') - script = script.replace(old, new) - Path('/tmp/wardnet-dnsbl-repair.py').write_text(script) - PY - python3 -m py_compile /tmp/wardnet-dnsbl-repair.py - python3 /tmp/wardnet-dnsbl-repair.py - cargo fmt --all - git diff --check - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - toolchain: stable - - name: Test and lint repaired tree - shell: bash - run: | - set -euo pipefail - cargo test --locked --workspace - cargo clippy --locked --workspace --all-targets -- -D warnings - - name: Commit only the causal repair and remove temporary workflows - shell: bash - run: | - set -euo pipefail - changed="$(git diff --name-only | sort)" - expected=$'crates/waf-ids-core/src/lib.rs\nsrc/lib.rs' - test "${changed}" = "${expected}" - git rm .github/workflows/wardnet-dnsbl-repair.yml .github/workflows/wardnet-dnsbl-repair-macos.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add crates/waf-ids-core/src/lib.rs src/lib.rs - git commit -m "fix(security): reconcile DNSBL feed ownership" - git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" - test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" - git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" diff --git a/.github/workflows/wardnet-dnsbl-repair.yml b/.github/workflows/wardnet-dnsbl-repair.yml deleted file mode 100644 index 19a697a..0000000 --- a/.github/workflows/wardnet-dnsbl-repair.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Wardnet DNSBL ownership repair (temporary) - -on: - push: - branches: - - fix/misp-to-ids-fail-closed - paths: - - .github/workflows/wardnet-dnsbl-repair.yml - -permissions: - contents: write - -concurrency: - group: wardnet-dnsbl-repair-${{ github.ref }} - cancel-in-progress: false - -jobs: - repair: - runs-on: ubuntu-24.04 - steps: - - name: Checkout triggering head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - - name: Refuse stale-parent execution - shell: bash - run: | - set -euo pipefail - git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" - test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" - - name: Apply shared DNSBL ownership repair - shell: bash - run: | - set -euo pipefail - printf '%s' 'ZnJvbSBwYXRobGliIGltcG9ydCBQYXRoCgpkZWYgcmVwbGFjZV9leGFjdChwYXRoOiBzdHIsIG9sZDogc3RyLCBuZXc6IHN0ciwgZXhwZWN0ZWQ6IGludCA9IDEpIC0+IE5vbmU6CiAgICBmaWxlID0gUGF0aChwYXRoKQogICAgdGV4dCA9IGZpbGUucmVhZF90ZXh0KCkKICAgIGNvdW50ID0gdGV4dC5jb3VudChvbGQpCiAgICBpZiBjb3VudCAhPSBleHBlY3RlZDoKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KGYie3BhdGh9OiBleHBlY3RlZCB7ZXhwZWN0ZWR9IG9jY3VycmVuY2VzLCBmb3VuZCB7Y291bnR9OiB7b2xkWzo4MF0hcn0iKQogICAgZmlsZS53cml0ZV90ZXh0KHRleHQucmVwbGFjZShvbGQsIG5ldykpCgpjb3JlID0gImNyYXRlcy93YWYtaWRzLWNvcmUvc3JjL2xpYi5ycyIKcm9vdCA9ICJzcmMvbGliLnJzIgoKcmVwbGFjZV9leGFjdCgKICAgIGNvcmUsCiAgICAiIiIgICAgI1tzZXJkZShkZWZhdWx0KV0KICAgIHB1YiBvcGVyYXRvcl90aHJlYXRfa2V5czogVmVjPFRocmVhdEluZGljYXRvcktleT4sCiAgICBwdWIgZG5zYmw6IFZlYzxEbnNibEVudHJ5PiwKIiIiLAogICAgIiIiICAgICNbc2VyZGUoZGVmYXVsdCldCiAgICBwdWIgb3BlcmF0b3JfdGhyZWF0X2tleXM6IFZlYzxUaHJlYXRJbmRpY2F0b3JLZXk+LAogICAgLy8vIEROU0JMIGlkZW50aXRpZXMgaW5kZXBlbmRlbnRseSBtYW5hZ2VkIHRocm91Z2ggdGhlIG9wZXJhdG9yIEFQSS4KICAgICNbc2VyZGUoZGVmYXVsdCldCiAgICBwdWIgb3BlcmF0b3JfZG5zYmxfa2V5czogVmVjPERuc2JsRW50cnlLZXk+LAogICAgcHViIGRuc2JsOiBWZWM8RG5zYmxFbnRyeT4sCiIiIiwKKQpyZXBsYWNlX2V4YWN0KAogICAgY29yZSwKICAgICIiIiAgICAgICAgICAgIG9wZXJhdG9yX3RocmVhdF9rZXlzOiBWZWM6Om5ldygpLAogICAgICAgICAgICBkbnNibDogdmVjIVtEbnNibEVudHJ5IHsKIiIiLAogICAgIiIiICAgICAgICAgICAgb3BlcmF0b3JfdGhyZWF0X2tleXM6IFZlYzo6bmV3KCksCiAgICAgICAgICAgIG9wZXJhdG9yX2Ruc2JsX2tleXM6IFZlYzo6bmV3KCksCiAgICAgICAgICAgIGRuc2JsOiB2ZWMhW0Ruc2JsRW50cnkgewoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIGNvcmUsCiAgICAiIiJ9CgojW2Rlcml2ZShEZWJ1ZywgQ2xvbmUsIFNlcmlhbGl6ZSwgRGVzZXJpYWxpemUsIFBhcnRpYWxFcSwgRXEpXQpwdWIgc3RydWN0IENvbW1lcmNpYWxQcm9maWxlIHsKIiIiLAogICAgIiIifQoKLy8vIFN0YWJsZSBETlNCTCBvd25lcnNoaXAgaWRlbnRpdHkuIEl0IGRlbGliZXJhdGVseSBtYXRjaGVzIFtgdXBzZXJ0X2Ruc2JsYF0sCi8vLyB3aG9zZSBwaHlzaWNhbCByb3cgaWRlbnRpdHkgaXMgdGhlIElQIGFkZHJlc3MgcmF0aGVyIHRoYW4gcGF5bG9hZCBtZXRhZGF0YS4KI1tkZXJpdmUoRGVidWcsIENsb25lLCBDb3B5LCBTZXJpYWxpemUsIERlc2VyaWFsaXplLCBQYXJ0aWFsRXEsIEVxLCBIYXNoKV0KI1tzZXJkZSh0cmFuc3BhcmVudCldCnB1YiBzdHJ1Y3QgRG5zYmxFbnRyeUtleShwdWIgSXBBZGRyKTsKCi8vLyBSZXR1cm5zIHRoZSBkdXJhYmxlIG93bmVyc2hpcCBpZGVudGl0eSBmb3IgYSBETlNCTCBlbnRyeS4KcHViIGZuIGRuc2JsX2VudHJ5X2tleShlbnRyeTogJkRuc2JsRW50cnkpIC0+IERuc2JsRW50cnlLZXkgewogICAgRG5zYmxFbnRyeUtleShlbnRyeS5hZGRyZXNzKQp9CgojW2Rlcml2ZShEZWJ1ZywgQ2xvbmUsIFNlcmlhbGl6ZSwgRGVzZXJpYWxpemUsIFBhcnRpYWxFcSwgRXEpXQpwdWIgc3RydWN0IENvbW1lcmNpYWxQcm9maWxlIHsKIiIiLAopCnJlcGxhY2VfZXhhY3QoCiAgICBjb3JlLAogICAgIiIicHViIHN0cnVjdCBUaHJlYXRGZWVkT3duZXJzaGlwIHsKICAgIHB1YiBmZWVkX2lkOiBTdHJpbmcsCiAgICBwdWIgdGhyZWF0X2tleXM6IFZlYzxUaHJlYXRJbmRpY2F0b3JLZXk+LAp9CiIiIiwKICAgICIiInB1YiBzdHJ1Y3QgVGhyZWF0RmVlZE93bmVyc2hpcCB7CiAgICBwdWIgZmVlZF9pZDogU3RyaW5nLAogICAgcHViIHRocmVhdF9rZXlzOiBWZWM8VGhyZWF0SW5kaWNhdG9yS2V5PiwKICAgIC8vLyBETlNCTCByb3dzIGN1cnJlbnRseSBjbGFpbWVkIGJ5IHRoaXMgZmVlZCBzbmFwc2hvdC4KICAgICNbc2VyZGUoZGVmYXVsdCldCiAgICBwdWIgZG5zYmxfa2V5czogVmVjPERuc2JsRW50cnlLZXk+LAp9CiIiIiwKKQpyZXBsYWNlX2V4YWN0KAogICAgY29yZSwKICAgICIiIiAgICAgICAgb3duZXJzaGlwLnB1c2goVGhyZWF0RmVlZE93bmVyc2hpcCB7CiAgICAgICAgICAgIGZlZWRfaWQsCiAgICAgICAgICAgIHRocmVhdF9rZXlzLAogICAgICAgIH0pOwogICAgICAgIFZlYzo6bmV3KCkKICAgIH0KfQoKcHViIGZuIHJlY29yZF9hdWRpdF9sb2ciIiIsCiAgICAiIiIgICAgICAgIG93bmVyc2hpcC5wdXNoKFRocmVhdEZlZWRPd25lcnNoaXAgewogICAgICAgICAgICBmZWVkX2lkLAogICAgICAgICAgICB0aHJlYXRfa2V5cywKICAgICAgICAgICAgZG5zYmxfa2V5czogVmVjOjpuZXcoKSwKICAgICAgICB9KTsKICAgICAgICBWZWM6Om5ldygpCiAgICB9Cn0KCi8vLyBSZXBsYWNlcyBvbmUgZmVlZCdzIEROU0JMIHNuYXBzaG90IG93bmVyc2hpcCBhbmQgcmV0dXJucyBpdHMgcHJpb3Iga2V5cy4KcHViIGZuIHJlcGxhY2VfdGhyZWF0X2ZlZWRfZG5zYmxfb3duZXJzaGlwKAogICAgb3duZXJzaGlwOiAmbXV0IFZlYzxUaHJlYXRGZWVkT3duZXJzaGlwPiwKICAgIGZlZWRfaWQ6IFN0cmluZywKICAgIGRuc2JsX2tleXM6IFZlYzxEbnNibEVudHJ5S2V5PiwKKSAtPiBWZWM8RG5zYmxFbnRyeUtleT4gewogICAgaWYgbGV0IFNvbWUoZXhpc3RpbmcpID0gb3duZXJzaGlwLml0ZXJfbXV0KCkuZmluZCh8aXRlbXwgaXRlbS5mZWVkX2lkID09IGZlZWRfaWQpIHsKICAgICAgICBsZXQgcHJldmlvdXMgPSBleGlzdGluZy5kbnNibF9rZXlzLmNsb25lKCk7CiAgICAgICAgZXhpc3RpbmcuZG5zYmxfa2V5cyA9IGRuc2JsX2tleXM7CiAgICAgICAgcHJldmlvdXMKICAgIH0gZWxzZSB7CiAgICAgICAgb3duZXJzaGlwLnB1c2goVGhyZWF0RmVlZE93bmVyc2hpcCB7CiAgICAgICAgICAgIGZlZWRfaWQsCiAgICAgICAgICAgIHRocmVhdF9rZXlzOiBWZWM6Om5ldygpLAogICAgICAgICAgICBkbnNibF9rZXlzLAogICAgICAgIH0pOwogICAgICAgIFZlYzo6bmV3KCkKICAgIH0KfQoKcHViIGZuIHJlY29yZF9hdWRpdF9sb2ciIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIGNvcmUsCiAgICAiIiIgICAgI1t0ZXN0XQogICAgZm4gcmVjb3Jkc19hdWRpdF9sb2dzX3dpdGhfbW9ub3RvbmljX2lkcygpIHsKIiIiLAogICAgIiIiICAgICNbdGVzdF0KICAgIGZuIHJlcGxhY2VzX3RocmVhdF9mZWVkX2Ruc2JsX293bmVyc2hpcF9hbmRfcmV0dXJuc19wcmV2aW91c19rZXlzKCkgewogICAgICAgIGxldCBmaXJzdCA9IERuc2JsRW50cnlLZXkoIjIwMy4wLjExMy4xIi5wYXJzZSgpLnVud3JhcCgpKTsKICAgICAgICBsZXQgc2Vjb25kID0gRG5zYmxFbnRyeUtleSgiMjAzLjAuMTEzLjIiLnBhcnNlKCkudW53cmFwKCkpOwogICAgICAgIGxldCBtdXQgb3duZXJzaGlwID0gVmVjOjpuZXcoKTsKCiAgICAgICAgYXNzZXJ0IShyZXBsYWNlX3RocmVhdF9mZWVkX2Ruc2JsX293bmVyc2hpcCgKICAgICAgICAgICAgJm11dCBvd25lcnNoaXAsCiAgICAgICAgICAgICJmZWVkLWEiLnRvX3N0cmluZygpLAogICAgICAgICAgICB2ZWMhW2ZpcnN0XSwKICAgICAgICApCiAgICAgICAgLmlzX2VtcHR5KCkpOwogICAgICAgIGFzc2VydF9lcSEob3duZXJzaGlwWzBdLmRuc2JsX2tleXMsIHZlYyFbZmlyc3RdKTsKCiAgICAgICAgbGV0IHByZXZpb3VzID0gcmVwbGFjZV90aHJlYXRfZmVlZF9kbnNibF9vd25lcnNoaXAoCiAgICAgICAgICAgICZtdXQgb3duZXJzaGlwLAogICAgICAgICAgICAiZmVlZC1hIi50b19zdHJpbmcoKSwKICAgICAgICAgICAgdmVjIVtzZWNvbmRdLAogICAgICAgICk7CiAgICAgICAgYXNzZXJ0X2VxIShwcmV2aW91cywgdmVjIVtmaXJzdF0pOwogICAgICAgIGFzc2VydF9lcSEob3duZXJzaGlwWzBdLmRuc2JsX2tleXMsIHZlYyFbc2Vjb25kXSk7CgogICAgICAgIGFzc2VydCEocmVwbGFjZV90aHJlYXRfZmVlZF9kbnNibF9vd25lcnNoaXAoCiAgICAgICAgICAgICZtdXQgb3duZXJzaGlwLAogICAgICAgICAgICAiZmVlZC1iIi50b19zdHJpbmcoKSwKICAgICAgICAgICAgdmVjIVtmaXJzdF0sCiAgICAgICAgKQogICAgICAgIC5pc19lbXB0eSgpKTsKICAgICAgICBhc3NlcnRfZXEhKG93bmVyc2hpcC5sZW4oKSwgMik7CiAgICAgICAgYXNzZXJ0IShvd25lcnNoaXBbMV0udGhyZWF0X2tleXMuaXNfZW1wdHkoKSk7CiAgICB9CgogICAgI1t0ZXN0XQogICAgZm4gZG5zYmxfb3duZXJzaGlwX2ZpZWxkc19kZWZhdWx0X3doZW5fZGVzZXJpYWxpemluZ19wcmVkZWNlc3Nvcl9zdGF0ZSgpIHsKICAgICAgICBsZXQga2V5ID0gRG5zYmxFbnRyeUtleSgiMjAzLjAuMTEzLjMiLnBhcnNlKCkudW53cmFwKCkpOwogICAgICAgIGxldCBtdXQgZGF0YSA9IEFwcERhdGE6OnNlZWRlZCgpOwogICAgICAgIGRhdGEub3BlcmF0b3JfZG5zYmxfa2V5cy5wdXNoKGtleSk7CiAgICAgICAgZGF0YS50aHJlYXRfZmVlZF9vd25lcnNoaXAucHVzaChUaHJlYXRGZWVkT3duZXJzaGlwIHsKICAgICAgICAgICAgZmVlZF9pZDogImxlZ2FjeS1mZWVkIi50b19zdHJpbmcoKSwKICAgICAgICAgICAgdGhyZWF0X2tleXM6IFZlYzo6bmV3KCksCiAgICAgICAgICAgIGRuc2JsX2tleXM6IHZlYyFba2V5XSwKICAgICAgICB9KTsKCiAgICAgICAgbGV0IG11dCBsZWdhY3kgPSBzZXJkZV9qc29uOjp0b192YWx1ZShkYXRhKS51bndyYXAoKTsKICAgICAgICBsZXQgb2JqZWN0ID0gbGVnYWN5LmFzX29iamVjdF9tdXQoKS51bndyYXAoKTsKICAgICAgICBvYmplY3QucmVtb3ZlKCJvcGVyYXRvcl9kbnNibF9rZXlzIik7CiAgICAgICAgb2JqZWN0WyJ0aHJlYXRfZmVlZF9vd25lcnNoaXAiXS5hc19hcnJheV9tdXQoKS51bndyYXAoKVswXQogICAgICAgICAgICAuYXNfb2JqZWN0X211dCgpCiAgICAgICAgICAgIC51bndyYXAoKQogICAgICAgICAgICAucmVtb3ZlKCJkbnNibF9rZXlzIik7CgogICAgICAgIGxldCBsb2FkZWQ6IEFwcERhdGEgPSBzZXJkZV9qc29uOjpmcm9tX3ZhbHVlKGxlZ2FjeSkudW53cmFwKCk7CiAgICAgICAgYXNzZXJ0IShsb2FkZWQub3BlcmF0b3JfZG5zYmxfa2V5cy5pc19lbXB0eSgpKTsKICAgICAgICBhc3NlcnQhKGxvYWRlZC50aHJlYXRfZmVlZF9vd25lcnNoaXBbMF0uZG5zYmxfa2V5cy5pc19lbXB0eSgpKTsKICAgIH0KCiAgICAjW3Rlc3RdCiAgICBmbiByZWNvcmRzX2F1ZGl0X2xvZ3Nfd2l0aF9tb25vdG9uaWNfaWRzKCkgewoiIiIsCikKCnJlcGxhY2VfZXhhY3QoCiAgICByb290LAogICAgIiIiICAgIEFwcERhdGEsIEJMT0NLX1NDT1JFLCBidXllcl9ldmlkZW5jZV9tYW5pZmVzdF9hdCwgY29tbWVyY2lhbF9yZWFkaW5lc3Nfc25hcHNob3RfYXQsCiAgICBlbmZvcmNlX2V2ZW50X2xpbWl0LCBrcGlfc25hcHNob3RfYXQsIHByb21ldGhldXNfZXhwb3NpdGlvbiwgcmF0ZV9saW1pdF9zdGVwLCByZWNvcmRfYXVkaXRfbG9nLAogICAgcmVwbGFjZV90aHJlYXRfZmVlZF9vd25lcnNoaXAsIHNlbGVjdF9yb3V0ZSwgc2lnbmF0dXJlX2NhdGFsb2csIHRocmVhdF9mZWVkX2ZyZXNobmVzc19zbmFwc2hvdCwKICAgIHRocmVhdF9pbmRpY2F0b3Jfa2V5LCB1cHNlcnRfZG5zYmwsIHVwc2VydF9yb3V0ZSwgdXBzZXJ0X3RocmVhdCwgdXBzZXJ0X3RocmVhdF9mZWVkLAoiIiIsCiAgICAiIiIgICAgQXBwRGF0YSwgQkxPQ0tfU0NPUkUsIGJ1eWVyX2V2aWRlbmNlX21hbmlmZXN0X2F0LCBjb21tZXJjaWFsX3JlYWRpbmVzc19zbmFwc2hvdF9hdCwKICAgIGRuc2JsX2VudHJ5X2tleSwgZW5mb3JjZV9ldmVudF9saW1pdCwga3BpX3NuYXBzaG90X2F0LCBwcm9tZXRoZXVzX2V4cG9zaXRpb24sIHJhdGVfbGltaXRfc3RlcCwKICAgIHJlY29yZF9hdWRpdF9sb2csIHJlcGxhY2VfdGhyZWF0X2ZlZWRfZG5zYmxfb3duZXJzaGlwLCByZXBsYWNlX3RocmVhdF9mZWVkX293bmVyc2hpcCwKICAgIHNlbGVjdF9yb3V0ZSwgc2lnbmF0dXJlX2NhdGFsb2csIHRocmVhdF9mZWVkX2ZyZXNobmVzc19zbmFwc2hvdCwgdGhyZWF0X2luZGljYXRvcl9rZXksCiAgICB1cHNlcnRfZG5zYmwsIHVwc2VydF9yb3V0ZSwgdXBzZXJ0X3RocmVhdCwgdXBzZXJ0X3RocmVhdF9mZWVkLAoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgQ29tbWVyY2lhbFByb2ZpbGUsIENvbW1lcmNpYWxSZWFkaW5lc3MsIERuc2JsRW50cnksIEVuZm9yY2VtZW50TW9kZSwgTGljZW5zZVN0YXR1cywKIiIiLAogICAgIiIiICAgIENvbW1lcmNpYWxQcm9maWxlLCBDb21tZXJjaWFsUmVhZGluZXNzLCBEbnNibEVudHJ5LCBEbnNibEVudHJ5S2V5LCBFbmZvcmNlbWVudE1vZGUsCiAgICBMaWNlbnNlU3RhdHVzLAoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgICAgICAgICBsZXQgc2F2ZWQgPSB1cHNlcnRfZG5zYmwoJm11dCBkYXRhLmRuc2JsLCBlbnRyeS5jbG9uZSgpKTsKICAgICAgICAgICAgcmVjb3JkX3N1Y2Nlc3NmdWxfYXVkaXRfbG9nKAoiIiIsCiAgICAiIiIgICAgICAgICAgICBsZXQgc2F2ZWQgPSB1cHNlcnRfZG5zYmwoJm11dCBkYXRhLmRuc2JsLCBlbnRyeS5jbG9uZSgpKTsKICAgICAgICAgICAgbWFya19vcGVyYXRvcl9kbnNibF9rZXkoZGF0YSwgJnNhdmVkKTsKICAgICAgICAgICAgcmVjb3JkX3N1Y2Nlc3NmdWxfYXVkaXRfbG9nKAoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiJmbiBtYXJrX29wZXJhdG9yX3RocmVhdF9rZXkoZGF0YTogJm11dCBBcHBEYXRhLCBpbmRpY2F0b3I6ICZUaHJlYXRJbmRpY2F0b3IpIHsKICAgIGxldCBrZXkgPSB0aHJlYXRfaW5kaWNhdG9yX2tleShpbmRpY2F0b3IpOwogICAgaWYgIWRhdGEub3BlcmF0b3JfdGhyZWF0X2tleXMuY29udGFpbnMoJmtleSkgewogICAgICAgIGRhdGEub3BlcmF0b3JfdGhyZWF0X2tleXMucHVzaChrZXkpOwogICAgfQp9Cgphc3luYyBmbiBhcHBseV90aHJlYXRfZmVlZF9pbXBvcnQiIiIsCiAgICAiIiJmbiBtYXJrX29wZXJhdG9yX3RocmVhdF9rZXkoZGF0YTogJm11dCBBcHBEYXRhLCBpbmRpY2F0b3I6ICZUaHJlYXRJbmRpY2F0b3IpIHsKICAgIGxldCBrZXkgPSB0aHJlYXRfaW5kaWNhdG9yX2tleShpbmRpY2F0b3IpOwogICAgaWYgIWRhdGEub3BlcmF0b3JfdGhyZWF0X2tleXMuY29udGFpbnMoJmtleSkgewogICAgICAgIGRhdGEub3BlcmF0b3JfdGhyZWF0X2tleXMucHVzaChrZXkpOwogICAgfQp9CgpmbiBtYXJrX29wZXJhdG9yX2Ruc2JsX2tleShkYXRhOiAmbXV0IEFwcERhdGEsIGVudHJ5OiAmRG5zYmxFbnRyeSkgewogICAgbGV0IGtleSA9IGRuc2JsX2VudHJ5X2tleShlbnRyeSk7CiAgICBpZiAhZGF0YS5vcGVyYXRvcl9kbnNibF9rZXlzLmNvbnRhaW5zKCZrZXkpIHsKICAgICAgICBkYXRhLm9wZXJhdG9yX2Ruc2JsX2tleXMucHVzaChrZXkpOwogICAgfQp9Cgphc3luYyBmbiBhcHBseV90aHJlYXRfZmVlZF9pbXBvcnQiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgICAgICAgICBsZXQgb3BlcmF0b3Jfb3duZWQ6IEhhc2hTZXQ8Xz4gPSBkYXRhLm9wZXJhdG9yX3RocmVhdF9rZXlzLml0ZXIoKS5jbG9uZWQoKS5jb2xsZWN0KCk7CiAgICAgICAgICAgIGxldCB0aHJlYXRfa2V5czogVmVjPF8+ID0gZmVlZC50aHJlYXRzLml0ZXIoKS5tYXAodGhyZWF0X2luZGljYXRvcl9rZXkpLmNvbGxlY3QoKTsKICAgICAgICAgICAgbGV0IHByZXZpb3VzX2tleXM6IEhhc2hTZXQ8Xz4gPSByZXBsYWNlX3RocmVhdF9mZWVkX293bmVyc2hpcCgKICAgICAgICAgICAgICAgICZtdXQgZGF0YS50aHJlYXRfZmVlZF9vd25lcnNoaXAsCiAgICAgICAgICAgICAgICBmZWVkLmZlZWRfaWQuY2xvbmUoKSwKICAgICAgICAgICAgICAgIHRocmVhdF9rZXlzLAogICAgICAgICAgICApCiAgICAgICAgICAgIC5pbnRvX2l0ZXIoKQogICAgICAgICAgICAuY29sbGVjdCgpOwoiIiIsCiAgICAiIiIgICAgICAgICAgICBsZXQgb3BlcmF0b3Jfb3duZWQ6IEhhc2hTZXQ8Xz4gPSBkYXRhLm9wZXJhdG9yX3RocmVhdF9rZXlzLml0ZXIoKS5jbG9uZWQoKS5jb2xsZWN0KCk7CiAgICAgICAgICAgIGxldCBvcGVyYXRvcl9kbnNibF9vd25lZDogSGFzaFNldDxfPiA9CiAgICAgICAgICAgICAgICBkYXRhLm9wZXJhdG9yX2Ruc2JsX2tleXMuaXRlcigpLmNvcGllZCgpLmNvbGxlY3QoKTsKICAgICAgICAgICAgbGV0IHRocmVhdF9rZXlzOiBWZWM8Xz4gPSBmZWVkLnRocmVhdHMuaXRlcigpLm1hcCh0aHJlYXRfaW5kaWNhdG9yX2tleSkuY29sbGVjdCgpOwogICAgICAgICAgICBsZXQgcHJldmlvdXNfa2V5czogSGFzaFNldDxfPiA9IHJlcGxhY2VfdGhyZWF0X2ZlZWRfb3duZXJzaGlwKAogICAgICAgICAgICAgICAgJm11dCBkYXRhLnRocmVhdF9mZWVkX293bmVyc2hpcCwKICAgICAgICAgICAgICAgIGZlZWQuZmVlZF9pZC5jbG9uZSgpLAogICAgICAgICAgICAgICAgdGhyZWF0X2tleXMsCiAgICAgICAgICAgICkKICAgICAgICAgICAgLmludG9faXRlcigpCiAgICAgICAgICAgIC5jb2xsZWN0KCk7CiAgICAgICAgICAgIGxldCBkbnNibF9rZXlzOiBWZWM8Xz4gPSBmZWVkLmRuc2JsLml0ZXIoKS5tYXAoZG5zYmxfZW50cnlfa2V5KS5jb2xsZWN0KCk7CiAgICAgICAgICAgIGxldCBwcmV2aW91c19kbnNibF9rZXlzOiBIYXNoU2V0PF8+ID0gcmVwbGFjZV90aHJlYXRfZmVlZF9kbnNibF9vd25lcnNoaXAoCiAgICAgICAgICAgICAgICAmbXV0IGRhdGEudGhyZWF0X2ZlZWRfb3duZXJzaGlwLAogICAgICAgICAgICAgICAgZmVlZC5mZWVkX2lkLmNsb25lKCksCiAgICAgICAgICAgICAgICBkbnNibF9rZXlzLAogICAgICAgICAgICApCiAgICAgICAgICAgIC5pbnRvX2l0ZXIoKQogICAgICAgICAgICAuY29sbGVjdCgpOwoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgICAgICAgICAgICAgfSk7CiAgICAgICAgICAgIH0KICAgICAgICAgICAgbGV0IG11dCB1cHNlcnRlZF90aHJlYXRzID0gMHVzaXplOwoiIiIsCiAgICAiIiIgICAgICAgICAgICAgICAgfSk7CiAgICAgICAgICAgIH0KICAgICAgICAgICAgaWYgIXByZXZpb3VzX2Ruc2JsX2tleXMuaXNfZW1wdHkoKSB7CiAgICAgICAgICAgICAgICBsZXQgc3RpbGxfZG5zYmxfb3duZWQ6IEhhc2hTZXQ8Xz4gPSBkYXRhCiAgICAgICAgICAgICAgICAgICAgLnRocmVhdF9mZWVkX293bmVyc2hpcAogICAgICAgICAgICAgICAgICAgIC5pdGVyKCkKICAgICAgICAgICAgICAgICAgICAuZmlsdGVyKHxvd25lcnNoaXB8IG93bmVyc2hpcC5mZWVkX2lkICE9IGZlZWQuZmVlZF9pZCkKICAgICAgICAgICAgICAgICAgICAuZmxhdF9tYXAofG93bmVyc2hpcHwgb3duZXJzaGlwLmRuc2JsX2tleXMuaXRlcigpLmNvcGllZCgpKQogICAgICAgICAgICAgICAgICAgIC5jb2xsZWN0KCk7CiAgICAgICAgICAgICAgICBkYXRhLmRuc2JsLnJldGFpbih8ZW50cnl8IHsKICAgICAgICAgICAgICAgICAgICBsZXQga2V5ID0gZG5zYmxfZW50cnlfa2V5KGVudHJ5KTsKICAgICAgICAgICAgICAgICAgICAhcHJldmlvdXNfZG5zYmxfa2V5cy5jb250YWlucygma2V5KQogICAgICAgICAgICAgICAgICAgICAgICB8fCBzdGlsbF9kbnNibF9vd25lZC5jb250YWlucygma2V5KQogICAgICAgICAgICAgICAgICAgICAgICB8fCBvcGVyYXRvcl9kbnNibF9vd25lZC5jb250YWlucygma2V5KQogICAgICAgICAgICAgICAgfSk7CiAgICAgICAgICAgIH0KICAgICAgICAgICAgbGV0IG11dCB1cHNlcnRlZF90aHJlYXRzID0gMHVzaXplOwoiIiIsCikKcmVwbGFjZV9leGFjdCgKICAgIHJvb3QsCiAgICAiIiIgICAgICAgICAgICBmb3IgZW50cnkgaW4gZmVlZC5kbnNibC5pdGVyKCkuY2xvbmVkKCkgewogICAgICAgICAgICAgICAgdXBzZXJ0X2Ruc2JsKCZtdXQgZGF0YS5kbnNibCwgZW50cnkpOwogICAgICAgICAgICB9CiIiIiwKICAgICIiIiAgICAgICAgICAgIGxldCBtdXQgdXBzZXJ0ZWRfZG5zYmwgPSAwdXNpemU7CiAgICAgICAgICAgIGZvciBlbnRyeSBpbiBmZWVkLmRuc2JsLml0ZXIoKS5jbG9uZWQoKSB7CiAgICAgICAgICAgICAgICBpZiBvcGVyYXRvcl9kbnNibF9vd25lZC5jb250YWlucygmZG5zYmxfZW50cnlfa2V5KCZlbnRyeSkpIHsKICAgICAgICAgICAgICAgICAgICBjb250aW51ZTsKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIHVwc2VydF9kbnNibCgmbXV0IGRhdGEuZG5zYmwsIGVudHJ5KTsKICAgICAgICAgICAgICAgIHVwc2VydGVkX2Ruc2JsICs9IDE7CiAgICAgICAgICAgIH0KIiIiLAopCnJlcGxhY2VfZXhhY3QoCiAgICByb290LAogICAgIiIiICAgICAgICAgICAgICAgIHVwc2VydGVkX3RocmVhdHMsCiAgICAgICAgICAgICAgICB1cHNlcnRlZF9kbnNibDogZmVlZC5kbnNibC5sZW4oKSwKIiIiLAogICAgIiIiICAgICAgICAgICAgICAgIHVwc2VydGVkX3RocmVhdHMsCiAgICAgICAgICAgICAgICB1cHNlcnRlZF9kbnNibCwKIiIiLAopCnJlcGxhY2VfZXhhY3QoCiAgICByb290LAogICAgIiIiICAgICAgICAgICAgICAgIG9wZXJhdG9yX3RocmVhdF9rZXlzOiBWZWM6Om5ldygpLAogICAgICAgICAgICAgICAgZG5zYmw6IFZlYzo6bmV3KCksCiIiLAogICAgIiIiICAgICAgICAgICAgICAgIG9wZXJhdG9yX3RocmVhdF9rZXlzOiBWZWM6Om5ldygpLAogICAgICAgICAgICAgICAgb3BlcmF0b3JfZG5zYmxfa2V5czogVmVjOjpuZXcoKSwKICAgICAgICAgICAgICAgIGRuc2JsOiBWZWM6Om5ldygpLAoiIiIsCiAgICBleHBlY3RlZD0yLAopCnJlcGxhY2VfZXhhY3QoCiAgICByb290LAogICAgIiIiICAgICNbdG9raW86OnRlc3RdCiAgICBhc3luYyBmbiBnYXRld2F5X2NvdmVyc19tb25pdG9yX3Byb3h5X25vdF9mb3VuZF9hbmRfYmFkX2dhdGV3YXlfcGF0aHMoKSB7CiIiLAogICAgIiIiICAgICNbdGVzdF0KICAgIGZuIG9wZXJhdG9yX2Ruc2JsX2tleV9tYXJraW5nX2lzX2lkZW1wb3RlbnQoKSB7CiAgICAgICAgbGV0IG11dCBkYXRhID0gQXBwRGF0YTo6c2VlZGVkKCk7CiAgICAgICAgbGV0IGVudHJ5ID0gRG5zYmxFbnRyeSB7CiAgICAgICAgICAgIGFkZHJlc3M6ICIyMDMuMC4xMTMuMjQ1Ii5wYXJzZSgpLnVud3JhcCgpLAogICAgICAgICAgICBjb2RlOiAiMTI3LjAuMC4yIi50b19zdHJpbmcoKSwKICAgICAgICAgICAgcmVhc29uOiAib3BlcmF0b3IiLnRvX3N0cmluZygpLAogICAgICAgICAgICBzb3VyY2U6ICJvcGVyYXRvciIudG9fc3RyaW5nKCksCiAgICAgICAgICAgIHR0bF9zZWNvbmRzOiAzMDAsCiAgICAgICAgICAgIHByZWZpeF9sZW46IE5vbmUsCiAgICAgICAgfTsKCiAgICAgICAgbWFya19vcGVyYXRvcl9kbnNibF9rZXkoJm11dCBkYXRhLCAmZW50cnkpOwogICAgICAgIG1hcmtfb3BlcmF0b3JfZG5zYmxfa2V5KCZtdXQgZGF0YSwgJmVudHJ5KTsKICAgICAgICBhc3NlcnRfZXEhKGRhdGEub3BlcmF0b3JfZG5zYmxfa2V5cywgdmVjIVtkbnNibF9lbnRyeV9rZXkoJmVudHJ5KV0pOwogICAgfQoKICAgICNbdG9raW86OnRlc3RdCiAgICBhc3luYyBmbiBnYXRld2F5X2NvdmVyc19tb25pdG9yX3Byb3h5X25vdF9mb3VuZF9hbmRfYmFkX2dhdGV3YXlfcGF0aHMoKSB7CiIiLAopCg==' | base64 -d >/tmp/wardnet-dnsbl-repair.py - python3 /tmp/wardnet-dnsbl-repair.py - cargo fmt --all - git diff --check - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - with: - toolchain: stable - - name: Test and lint repaired tree - shell: bash - run: | - set -euo pipefail - cargo test --locked --workspace - cargo clippy --locked --workspace --all-targets -- -D warnings - - name: Commit only the causal repair and remove this workflow - shell: bash - run: | - set -euo pipefail - changed="$(git diff --name-only | sort)" - expected=$'crates/waf-ids-core/src/lib.rs\nsrc/lib.rs' - test "${changed}" = "${expected}" - git rm .github/workflows/wardnet-dnsbl-repair.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add crates/waf-ids-core/src/lib.rs src/lib.rs - git commit -m "fix(security): reconcile DNSBL feed ownership" - git fetch origin "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" - test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" - git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" diff --git a/crates/waf-ids-core/src/lib.rs b/crates/waf-ids-core/src/lib.rs index f9673e0..33efeab 100644 --- a/crates/waf-ids-core/src/lib.rs +++ b/crates/waf-ids-core/src/lib.rs @@ -13,6 +13,9 @@ pub struct AppData { pub threats: Vec, #[serde(default)] pub operator_threat_keys: Vec, + /// DNSBL identities independently managed through the operator API. + #[serde(default)] + pub operator_dnsbl_keys: Vec, pub dnsbl: Vec, pub events: Vec, pub next_event_id: u64, @@ -47,6 +50,7 @@ impl AppData { ttl_seconds: 86_400, }], operator_threat_keys: Vec::new(), + operator_dnsbl_keys: Vec::new(), dnsbl: vec![DnsblEntry { address: "203.0.113.10".parse().expect("seed IP address is valid"), code: "127.0.0.2".to_string(), @@ -138,6 +142,17 @@ pub struct DnsblEntry { pub prefix_len: Option, } +/// Stable DNSBL ownership identity. It deliberately matches [`upsert_dnsbl`], +/// whose physical row identity is the IP address rather than payload metadata. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(transparent)] +pub struct DnsblEntryKey(pub IpAddr); + +/// Returns the durable ownership identity for a DNSBL entry. +pub fn dnsbl_entry_key(entry: &DnsblEntry) -> DnsblEntryKey { + DnsblEntryKey(entry.address) +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CommercialProfile { pub tenant_id: String, @@ -189,6 +204,9 @@ pub struct ThreatFeedStatus { pub struct ThreatFeedOwnership { pub feed_id: String, pub threat_keys: Vec, + /// DNSBL rows currently claimed by this feed snapshot. + #[serde(default)] + pub dnsbl_keys: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -573,6 +591,27 @@ pub fn replace_threat_feed_ownership( ownership.push(ThreatFeedOwnership { feed_id, threat_keys, + dnsbl_keys: Vec::new(), + }); + Vec::new() + } +} + +/// Replaces one feed's DNSBL snapshot ownership and returns its prior keys. +pub fn replace_threat_feed_dnsbl_ownership( + ownership: &mut Vec, + feed_id: String, + dnsbl_keys: Vec, +) -> Vec { + if let Some(existing) = ownership.iter_mut().find(|item| item.feed_id == feed_id) { + let previous = existing.dnsbl_keys.clone(); + existing.dnsbl_keys = dnsbl_keys; + previous + } else { + ownership.push(ThreatFeedOwnership { + feed_id, + threat_keys: Vec::new(), + dnsbl_keys, }); Vec::new() } @@ -1776,6 +1815,55 @@ mod tests { assert!(shannon_entropy(b"abcdefgh") > 2.9); } + #[test] + fn replaces_threat_feed_dnsbl_ownership_and_returns_previous_keys() { + let first = DnsblEntryKey("203.0.113.1".parse().unwrap()); + let second = DnsblEntryKey("203.0.113.2".parse().unwrap()); + let mut ownership = Vec::new(); + + assert!( + replace_threat_feed_dnsbl_ownership(&mut ownership, "feed-a".to_string(), vec![first],) + .is_empty() + ); + assert_eq!(ownership[0].dnsbl_keys, vec![first]); + + let previous = + replace_threat_feed_dnsbl_ownership(&mut ownership, "feed-a".to_string(), vec![second]); + assert_eq!(previous, vec![first]); + assert_eq!(ownership[0].dnsbl_keys, vec![second]); + + assert!( + replace_threat_feed_dnsbl_ownership(&mut ownership, "feed-b".to_string(), vec![first],) + .is_empty() + ); + assert_eq!(ownership.len(), 2); + assert!(ownership[1].threat_keys.is_empty()); + } + + #[test] + fn dnsbl_ownership_fields_default_when_deserializing_predecessor_state() { + let key = DnsblEntryKey("203.0.113.3".parse().unwrap()); + let mut data = AppData::seeded(); + data.operator_dnsbl_keys.push(key); + data.threat_feed_ownership.push(ThreatFeedOwnership { + feed_id: "legacy-feed".to_string(), + threat_keys: Vec::new(), + dnsbl_keys: vec![key], + }); + + let mut legacy = serde_json::to_value(data).unwrap(); + let object = legacy.as_object_mut().unwrap(); + object.remove("operator_dnsbl_keys"); + object["threat_feed_ownership"].as_array_mut().unwrap()[0] + .as_object_mut() + .unwrap() + .remove("dnsbl_keys"); + + let loaded: AppData = serde_json::from_value(legacy).unwrap(); + assert!(loaded.operator_dnsbl_keys.is_empty()); + assert!(loaded.threat_feed_ownership[0].dnsbl_keys.is_empty()); + } + #[test] fn records_audit_logs_with_monotonic_ids() { let mut data = AppData::seeded(); diff --git a/src/lib.rs b/src/lib.rs index ab902ca..d1c66d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,17 +21,17 @@ use tokio::{ }; use waf_ids_core::{ AppData, BLOCK_SCORE, buyer_evidence_manifest_at, commercial_readiness_snapshot_at, - enforce_event_limit, kpi_snapshot_at, prometheus_exposition, rate_limit_step, record_audit_log, - replace_threat_feed_ownership, select_route, signature_catalog, threat_feed_freshness_snapshot, - threat_indicator_key, upsert_dnsbl, upsert_route, upsert_threat, upsert_threat_feed, - validate_commercial_profile, validate_dnsbl, validate_route, validate_threat, - validate_threat_feed_import, + dnsbl_entry_key, enforce_event_limit, kpi_snapshot_at, prometheus_exposition, rate_limit_step, + record_audit_log, replace_threat_feed_dnsbl_ownership, replace_threat_feed_ownership, + select_route, signature_catalog, threat_feed_freshness_snapshot, threat_indicator_key, + upsert_dnsbl, upsert_route, upsert_threat, upsert_threat_feed, validate_commercial_profile, + validate_dnsbl, validate_route, validate_threat, validate_threat_feed_import, }; pub use waf_ids_core::{ AuditLogEntry, BuyerEvidenceEndpoint, BuyerEvidenceManifest, BuyerEvidenceRuntimeCounts, - CommercialProfile, CommercialReadiness, DnsblEntry, EnforcementMode, LicenseStatus, - NewAuditLogEntry, ProductEdition, ReadinessCheck, ReadinessStatus, RouteConfig, ScoredRequest, - SecurityEvent, Severity, SignatureInfo, SocKpiSnapshot, TARGET_SALE_VALUE_KRW, + CommercialProfile, CommercialReadiness, DnsblEntry, DnsblEntryKey, EnforcementMode, + LicenseStatus, NewAuditLogEntry, ProductEdition, ReadinessCheck, ReadinessStatus, RouteConfig, + ScoredRequest, SecurityEvent, Severity, SignatureInfo, SocKpiSnapshot, TARGET_SALE_VALUE_KRW, ThreatFeedFreshness, ThreatFeedImport, ThreatFeedImportResult, ThreatFeedStatus, ThreatIndicator, export_dnsbl_zone, ip_in_network, reverse_ipv4_for_dnsbl, score_request, }; @@ -996,6 +996,7 @@ async fn create_dnsbl( match state .mutate_and_persist(|data| { let saved = upsert_dnsbl(&mut data.dnsbl, entry.clone()); + mark_operator_dnsbl_key(data, &saved); record_successful_audit_log( data, actor, @@ -2702,6 +2703,13 @@ fn mark_operator_threat_key(data: &mut AppData, indicator: &ThreatIndicator) { } } +fn mark_operator_dnsbl_key(data: &mut AppData, entry: &DnsblEntry) { + let key = dnsbl_entry_key(entry); + if !data.operator_dnsbl_keys.contains(&key) { + data.operator_dnsbl_keys.push(key); + } +} + async fn apply_threat_feed_import( state: &AppState, actor: String, @@ -2712,6 +2720,8 @@ async fn apply_threat_feed_import( state .mutate_and_persist(|data| { let operator_owned: HashSet<_> = data.operator_threat_keys.iter().cloned().collect(); + let operator_dnsbl_owned: HashSet<_> = + data.operator_dnsbl_keys.iter().copied().collect(); let threat_keys: Vec<_> = feed.threats.iter().map(threat_indicator_key).collect(); let previous_keys: HashSet<_> = replace_threat_feed_ownership( &mut data.threat_feed_ownership, @@ -2720,6 +2730,14 @@ async fn apply_threat_feed_import( ) .into_iter() .collect(); + let dnsbl_keys: Vec<_> = feed.dnsbl.iter().map(dnsbl_entry_key).collect(); + let previous_dnsbl_keys: HashSet<_> = replace_threat_feed_dnsbl_ownership( + &mut data.threat_feed_ownership, + feed.feed_id.clone(), + dnsbl_keys, + ) + .into_iter() + .collect(); if !previous_keys.is_empty() { // A key this feed is dropping might still be owned by another // feed (e.g. two feeds importing the same CVE under a shared @@ -2740,6 +2758,20 @@ async fn apply_threat_feed_import( || operator_owned.contains(&key) }); } + if !previous_dnsbl_keys.is_empty() { + let still_dnsbl_owned: HashSet<_> = data + .threat_feed_ownership + .iter() + .filter(|ownership| ownership.feed_id != feed.feed_id) + .flat_map(|ownership| ownership.dnsbl_keys.iter().copied()) + .collect(); + data.dnsbl.retain(|entry| { + let key = dnsbl_entry_key(entry); + !previous_dnsbl_keys.contains(&key) + || still_dnsbl_owned.contains(&key) + || operator_dnsbl_owned.contains(&key) + }); + } let mut upserted_threats = 0usize; for threat in feed.threats.iter().cloned() { if operator_owned.contains(&threat_indicator_key(&threat)) { @@ -2748,8 +2780,13 @@ async fn apply_threat_feed_import( upsert_threat(&mut data.threats, threat); upserted_threats += 1; } + let mut upserted_dnsbl = 0usize; for entry in feed.dnsbl.iter().cloned() { + if operator_dnsbl_owned.contains(&dnsbl_entry_key(&entry)) { + continue; + } upsert_dnsbl(&mut data.dnsbl, entry); + upserted_dnsbl += 1; } upsert_threat_feed( &mut data.threat_feeds, @@ -2765,7 +2802,7 @@ async fn apply_threat_feed_import( let result = ThreatFeedImportResult { feed_id: feed.feed_id.clone(), upserted_threats, - upserted_dnsbl: feed.dnsbl.len(), + upserted_dnsbl, last_updated_unix: imported_at, }; record_successful_audit_log(data, actor, action, "threat_feed", result.feed_id.clone()); @@ -6500,6 +6537,23 @@ mod tests { ); } + #[test] + fn operator_dnsbl_key_marking_is_idempotent() { + let mut data = AppData::seeded(); + let entry = DnsblEntry { + address: "203.0.113.245".parse().unwrap(), + code: "127.0.0.2".to_string(), + reason: "operator".to_string(), + source: "operator".to_string(), + ttl_seconds: 300, + prefix_len: None, + }; + + mark_operator_dnsbl_key(&mut data, &entry); + mark_operator_dnsbl_key(&mut data, &entry); + assert_eq!(data.operator_dnsbl_keys, vec![dnsbl_entry_key(&entry)]); + } + #[tokio::test] async fn gateway_covers_monitor_proxy_not_found_and_bad_gateway_paths() { let upstream_app = Router::new().route( @@ -6563,6 +6617,7 @@ mod tests { ], threats: Vec::new(), operator_threat_keys: Vec::new(), + operator_dnsbl_keys: Vec::new(), dnsbl: Vec::new(), events: Vec::new(), next_event_id: 1, @@ -7440,6 +7495,7 @@ mod tests { }], threats: Vec::new(), operator_threat_keys: Vec::new(), + operator_dnsbl_keys: Vec::new(), dnsbl: Vec::new(), events: Vec::new(), next_event_id: 1, From 913a965cd05e5b07e7cdec9fc4966dd7efa41a4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:17:20 +0900 Subject: [PATCH 33/34] chore: stage protected-main restack --- .github/workflows/wardnet-restack-main.yml | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/wardnet-restack-main.yml diff --git a/.github/workflows/wardnet-restack-main.yml b/.github/workflows/wardnet-restack-main.yml new file mode 100644 index 0000000..c085095 --- /dev/null +++ b/.github/workflows/wardnet-restack-main.yml @@ -0,0 +1,61 @@ +name: Wardnet protected-main restack (temporary) + +on: + push: + branches: + - fix/misp-to-ids-fail-closed + paths: + - .github/workflows/wardnet-restack-main.yml + +permissions: + contents: write + +concurrency: + group: wardnet-restack-main-${{ github.ref }} + cancel-in-progress: false + +jobs: + restack: + runs-on: macos-15 + steps: + - name: Checkout triggering head + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Adopt the verified protected head without rewriting history + shell: bash + env: + EXPECTED_MAIN: a52ccd0a24a727d9349bb32def7713882d8cad1e + run: | + set -euo pipefail + git fetch origin main "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" + test "$(git rev-parse origin/main)" = "${EXPECTED_MAIN}" + test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" + git merge --no-ff --no-commit "${EXPECTED_MAIN}" + git rm .github/workflows/wardnet-restack-main.yml + cargo fmt --all -- --check + git diff --check --cached + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: stable + - name: Verify candidate-base compatibility + shell: bash + run: | + set -euo pipefail + cargo test --locked --workspace + cargo clippy --locked --workspace --all-targets -- -D warnings + - name: Commit and push only if both refs stayed unchanged + shell: bash + env: + EXPECTED_MAIN: a52ccd0a24a727d9349bb32def7713882d8cad1e + run: | + set -euo pipefail + git fetch origin main "${GITHUB_REF_NAME}:refs/remotes/origin/${GITHUB_REF_NAME}" + test "$(git rev-parse origin/main)" = "${EXPECTED_MAIN}" + test "$(git rev-parse "origin/${GITHUB_REF_NAME}")" = "${GITHUB_SHA}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "chore: adopt protected main a52ccd0" + git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" From 0c83cd5956f512d79c6600e823fcfa6d6f32af4e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 6 Sep 2026 09:19:30 +0900 Subject: [PATCH 34/34] docs(security): record DNSBL ownership GREEN --- docs/doctoring/threat-feed-dnsbl-ownership.md | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/docs/doctoring/threat-feed-dnsbl-ownership.md b/docs/doctoring/threat-feed-dnsbl-ownership.md index 97890c9..eef00fa 100644 --- a/docs/doctoring/threat-feed-dnsbl-ownership.md +++ b/docs/doctoring/threat-feed-dnsbl-ownership.md @@ -2,47 +2,56 @@ ## Decision boundary -`POST /api/threat-feeds/import` is a snapshot-reconciliation boundary, not an append-only DNSBL ingest path. A feed refresh may withdraw a previously published address. Wardnet must therefore distinguish four facts that the current global `Vec` cannot express by itself: +`POST /api/threat-feeds/import` is a snapshot-reconciliation boundary, not an append-only DNSBL ingest path. A feed refresh may withdraw a previously published address. Wardnet therefore distinguishes four facts that the global `Vec` cannot express by itself: 1. the stable DNSBL identity currently used by `upsert_dnsbl`; 2. which feed snapshots still claim that identity; 3. whether an operator independently owns that identity; and 4. the current effective payload stored for that identity. -The stable identity for this repair is the same identity the mutation primitive already enforces: IP `address`. Introducing a different ownership key would make reconciliation disagree with `upsert_dnsbl` and would permit two logical owners to mutate one physical row under incompatible identities. +The stable identity is the same identity the mutation primitive already enforces: IP `address`. Introducing a different ownership key would make reconciliation disagree with `upsert_dnsbl` and would permit two logical owners to mutate one physical row under incompatible identities. -## Required state model +## Implemented state model -The durable `AppData` authority must carry explicit DNSBL ownership rather than infer it from source strings, TTL, threat indicators, audit logs, or adapter-specific conventions. +The durable `AppData` authority now carries explicit DNSBL ownership rather than inferring it from source strings, TTL, threat indicators, audit logs, or adapter-specific conventions. -- Add a serializable/hashable `DnsblEntryKey` whose identity matches the global DNSBL upsert identity. -- Add `dnsbl_keys` to each `ThreatFeedOwnership`, with `#[serde(default)]` so persisted predecessor state migrates without a destructive rewrite. -- Add `operator_dnsbl_keys` to `AppData`, also `#[serde(default)]`. -- `/api/dnsbl` writes mark the address as operator-owned before replacing the effective DNSBL payload. -- A feed import replaces that feed's threat and DNSBL ownership sets as one snapshot mutation, then removes each previously-owned DNSBL row only when the address is absent from the new snapshot, absent from every other feed ownership set, and absent from operator ownership. -- Feed upsert must not overwrite an operator-owned payload at the same stable address. This mirrors the existing threat-indicator rule that operator-managed payload wins over feed refresh. +- `DnsblEntryKey(IpAddr)` is serializable/hashable and matches the global DNSBL upsert identity. +- Each `ThreatFeedOwnership` carries `dnsbl_keys` with `#[serde(default)]`, so persisted predecessor state loads without a destructive rewrite. +- `AppData` carries independent `operator_dnsbl_keys`, also with `#[serde(default)]`. +- `/api/dnsbl` upsert and operator-key registration occur in the same `mutate_and_persist` mutation, so either the effective payload plus ownership persist together or the mutation rolls back. +- A feed import replaces that feed's threat and DNSBL ownership sets as one snapshot mutation, then removes each previously owned DNSBL row only when no other feed and no operator owns the address. +- Feed import skips effective-payload writes for operator-owned addresses and increments `upserted_dnsbl` only for writes it actually performs. -Ownership metadata is internal control-plane state. It does not become threat intelligence, does not become a synthetic `ThreatIndicator`, and must not be encoded into `source`, audit-log text, or another bounded context. Those shortcuts were rejected because they would make authority implicit and break DDD naming/semantic boundaries. +Ownership metadata is internal control-plane state. It does not become threat intelligence, does not become a synthetic `ThreatIndicator`, and is not encoded into `source`, audit-log text, or another bounded context. Those shortcuts were rejected because they would make authority implicit and break DDD naming and semantic boundaries. ## Replay, persistence, and idempotency -A repeated identical feed snapshot must leave both ownership and effective DNSBL state unchanged apart from the feed freshness timestamp already owned by the import path. Restarting from persisted `AppData` must retain enough ownership to make the next refresh deterministic; an in-memory sidecar is therefore not sufficient. A refresh of feed A must never delete an address still owned by feed B. A later operator upsert at an address previously owned by a feed must survive withdrawal of that feed without payload rollback. +A repeated identical feed snapshot leaves ownership and effective DNSBL state semantically unchanged apart from the feed freshness timestamp already owned by the import path. Restarting from persisted `AppData` retains enough ownership to make the next refresh deterministic; an in-memory sidecar is not sufficient. A refresh of feed A cannot delete an address still owned by feed B. A later operator upsert at an address previously owned by a feed survives withdrawal of that feed without payload rollback. -The repair must remain inside Wardnet's shared threat-feed admission/control-plane path. MISP, STIX/TAXII, OpenCTI, KEV, and other adapters provide feed material but do not copy or reimplement reconciliation. This is why the valid review finding is repaired at `apply_threat_feed_import`, not inside `misp_import.rs`. +The repair remains inside Wardnet's shared threat-feed admission/control-plane path. MISP, STIX/TAXII, OpenCTI, KEV, and other adapters provide feed material but do not copy or reimplement reconciliation. This is why the valid review finding was repaired at `apply_threat_feed_import`, not inside `misp_import.rs`. -## Hostile RED contract +## Hostile RED and causal GREEN -`tests/threat_feed_dnsbl_ownership.rs` is the focused public-API regression. Exact RED `a639e626764e2caf593266dbf94d2b030626bbaf` proves the current state model lacks the first required cleanup behavior and cannot safely prove operator ownership: +`tests/threat_feed_dnsbl_ownership.rs` is the focused public-API regression. The corrected hostile lineage reached exact RED `28d0ac12d37b4c97ea58b2d55831a6c1e7b9cf98`: the existing implementation failed stale feed withdrawal and operator-overwrite isolation while the valid shared-feed control remained preserved. The refresh requests retain unrelated valid material so they reach snapshot reconciliation without changing the existing contract that rejects a completely empty feed import. -- import feed A with one DNSBL address, refresh A with an empty DNSBL snapshot, and require the withdrawn row to disappear; +The hostile contract requires: + +- import feed A with a DNSBL address, refresh A without that DNSBL key, and require the withdrawn row to disappear; - import the same address from feeds A and B, withdraw it from A, and require the row to remain because B still owns it; -- import an address from a feed, overwrite that address through the operator `/api/dnsbl` surface, withdraw the feed, and require the operator payload to survive byte-for-byte at the domain-field level. +- import an address from a feed, overwrite that address through the operator `/api/dnsbl` surface, withdraw the feed, and require the operator payload to survive at the domain-field level; +- reject feed overwrite of an operator-owned payload and report zero feed DNSBL writes for that skipped key. + +`tests/threat_feed_dnsbl_persistence.rs` additionally covers restart-before-withdrawal, predecessor-state deserialization and persistence-failure rollback/retry. A solution that merely stops gateway scoring while leaving stale `/api/dnsbl` state, relies on TTL expiry, synthesizes hidden threat indicators, or keeps ownership only in process memory does not satisfy the contract. + +The causal source repair was committed as `7042aa19267886e3af9c378dddd879929837877b`. Before that source-only commit was pushed, rescue run `34000662730` executed the resulting working tree: all locked workspace tests passed, including all four DNSBL ownership cases and all three persistence cases, and strict workspace Clippy passed. The workflow then verified that only `crates/waf-ids-core/src/lib.rs` and `src/lib.rs` were modified by the repair, removed both temporary repair workflows, and non-force pushed the causal commit. This is source-GREEN evidence, not a substitute for the required workflows on a later committed head. -GREEN requires all three tests plus existing threat-feed ownership regressions to pass on the same exact head. A solution that merely stops gateway scoring while leaving stale `/api/dnsbl` state, relies on TTL expiry, synthesizes hidden threat indicators, or keeps ownership only in process memory does not satisfy the contract. +## Protected-base compatibility and operational evidence -## Operational evidence +Protected `main` advanced independently through #171 to `a52ccd0a24a727d9349bb32def7713882d8cad1e`. A bounded non-force restack run `34000892973` checked the then-current #167 head, verified both the feature ref and protected-main SHA were unchanged, merged that exact protected head without rewriting history, removed its temporary restack workflow, and ran the full locked workspace tests plus strict workspace Clippy successfully before pushing merge commit `d8b452cf1d609bb6e9c9a8a33f265c0a32dce7c9`. Thus the causal Rust repair is candidate-base compatible with #171's protected truth. -The current central runner queue can delay remote execution, but queued/pre-checkout state is non-passing evidence rather than a reason to weaken this invariant. Merge remains prohibited until the exact repaired head obtains the repository-owned CI/Fuzz and all then-live security, coverage, SBOM, provenance, review-thread, and branch-integrity gates required by the protected ruleset. +The first standard required workflows on bot-authored source commit `7042aa1...` terminated `action_required` without jobs because that commit was produced by a `GITHUB_TOKEN` workflow; they are not GREEN evidence. A subsequent human-authored restack-control commit did trigger normal standard workflow materialization, with the Ubuntu CI lane entering the known queued class while the macOS restack runner acquired compute and completed. This narrows the runner evidence already handed to `.github#712`: the observed starvation is not an organization-wide inability to allocate any GitHub-hosted runner. + +Merge remains prohibited until the unchanged final committed head obtains the then-live repository/security/review/governance evidence required by the protected ruleset. Queued, `action_required`, predecessor-head, or working-tree evidence must not be promoted to an exact-head required-gate verdict. ## Security rationale @@ -52,12 +61,16 @@ The general protection rationale remains the fail-safe-default principle documen ## Acceptance -- `DnsblEntryKey` and its ownership fields are explicit, persisted, serde-defaulted, and code/API tests cover predecessor-state deserialization. +The production behavior and causal source verification now satisfy these implementation criteria: + +- `DnsblEntryKey` and its ownership fields are explicit, persisted, serde-defaulted, and tests cover predecessor-state deserialization; - ownership replacement and stale-row removal happen inside the same `mutate_and_persist` transaction as the feed snapshot; - same-address multiple-feed ownership prevents premature deletion; - operator ownership prevents feed deletion and feed payload overwrite; -- repeated snapshots are idempotent; -- `upserted_dnsbl` reports actual feed writes, not merely requested input length when an operator-owned row is preserved; +- persistence failure rolls ownership and effective state back before retry; +- repeated snapshots are idempotent at the domain-state level; +- `upserted_dnsbl` reports actual feed writes rather than requested input length when an operator-owned row is preserved; - existing threat ownership semantics remain unchanged; -- no adapter-specific copy of reconciliation logic is introduced; -- exact-current CI/Fuzz/security/coverage/SBOM/provenance/review evidence is terminal GREEN before merge. +- no adapter-specific copy of reconciliation logic is introduced. + +Release/merge acceptance remains separate: exact-current CI/Fuzz/security/coverage/SBOM/provenance/review/thread/governance evidence must be terminal-valid before protected merge, followed by fresh protected-head release evidence before any release-ready claim.