@hoanghainh/komagu 2.2.1 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/assess/lsa/CHANGELOG.md +15 -0
  3. package/assess/lsa/scripts/attack_chains.py +573 -0
  4. package/assess/lsa/scripts/baseline.py +14 -0
  5. package/assess/lsa/scripts/detect_stack.py +2 -1
  6. package/assess/lsa/scripts/findings_to_sarif.py +11 -0
  7. package/assess/lsa/scripts/forecast.py +544 -0
  8. package/assess/lsa/scripts/lsa_common.py +1 -5
  9. package/assess/lsa/scripts/merge_findings.py +14 -0
  10. package/assess/lsa/scripts/redaction.py +118 -0
  11. package/assess/lsa/scripts/render_report.py +86 -0
  12. package/assess/lsa/tests/test_attack_chains.py +292 -0
  13. package/assess/lsa/tests/test_forecast.py +160 -0
  14. package/assess/lsa/tests/test_modern_stacks.py +13 -0
  15. package/contracts/schemas/api-inventory-diff.schema.json +213 -0
  16. package/contracts/schemas/attack-chain.schema.json +181 -0
  17. package/contracts/schemas/bas-scenario.schema.json +217 -0
  18. package/contracts/schemas/evidence-manifest.schema.json +117 -0
  19. package/contracts/schemas/finding.schema.json +7 -0
  20. package/contracts/schemas/forecast.schema.json +172 -0
  21. package/governance/scs/__init__.py +21 -0
  22. package/governance/scs/mapping.py +133 -0
  23. package/governance/scs/pack.py +354 -0
  24. package/operate/ai/context.py +22 -2
  25. package/operate/cli/__init__.py +1 -1
  26. package/operate/cli/commands/evidence.py +138 -0
  27. package/operate/cli/commands/inventory.py +187 -0
  28. package/operate/cli/commands/simulate.py +175 -58
  29. package/operate/cli/main.py +7 -3
  30. package/operate/inventory/__init__.py +22 -0
  31. package/operate/inventory/api_inventory.py +566 -0
  32. package/operate/simulate/__init__.py +16 -0
  33. package/operate/simulate/engine.py +370 -0
  34. package/operate/simulate/loader.py +158 -0
  35. package/operate/simulate/scenarios/idc-frontier-ransomware.json +47 -0
  36. package/operate/simulate/scenarios/metabase-cve-2026-72898.json +84 -0
  37. package/operate/simulate/scenarios/times-car-breach.json +74 -0
  38. package/operate/simulate/scenarios.py +52 -0
  39. package/operate/tests/test_ai_redaction.py +181 -0
  40. package/operate/tests/test_api_inventory.py +239 -0
  41. package/operate/tests/test_declarative_scenarios.py +169 -0
  42. package/operate/tests/test_evidence_pack.py +128 -0
  43. package/operate/tests/test_headless_ai.py +16 -1
  44. package/package.json +1 -1
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for security debt forecasting (Issue #318).
3
+
4
+ Verifies:
5
+ 1. forecast.schema.json schema conformity.
6
+ 2. Insufficient history detection (< 6 snapshots or < 28 days span).
7
+ 3. Converging burndown calculations (P10 <= P50 <= P90).
8
+ 4. Flat and diverging trajectory handling without false promises.
9
+ 5. Target deadline evaluation (on_track, at_risk, off_track).
10
+ 6. What-if velocity simulation.
11
+ 7. Markdown (EN & JA) and HTML card rendering.
12
+
13
+ Standard-library-only (Python 3.8+).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import sys
19
+ import unittest
20
+ from pathlib import Path
21
+
22
+ HERE = Path(__file__).resolve().parent
23
+ KIT = HERE.parent
24
+ sys.path.insert(0, str(KIT / "scripts"))
25
+
26
+ from forecast import project_debt, render_forecast_html, render_forecast_md # noqa: E402
27
+ from merge_findings import load_validator # noqa: E402
28
+
29
+
30
+ class TestForecast(unittest.TestCase):
31
+ def setUp(self) -> None:
32
+ self.validate_schema = load_validator("forecast.schema.json")
33
+
34
+ def test_schema_validates_empty_and_insufficient_history(self) -> None:
35
+ doc = project_debt([])
36
+ errors = self.validate_schema(doc)
37
+ self.assertEqual(errors, [], f"Schema validation errors: {errors}")
38
+ self.assertFalse(doc["has_enough_history"])
39
+ self.assertIn("Requires at least", doc["reason_if_insufficient"])
40
+
41
+ def test_insufficient_snapshots_or_span(self) -> None:
42
+ # 4 snapshots over 10 days
43
+ history = [
44
+ {"date": "2026-09-01", "open": 20, "new": 2, "fixed": 1, "regressed": 0, "by_severity": {"critical": 5, "high": 10}},
45
+ {"date": "2026-09-03", "open": 19, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 4, "high": 10}},
46
+ {"date": "2026-09-06", "open": 18, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 3, "high": 10}},
47
+ {"date": "2026-09-10", "open": 16, "new": 0, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 10}},
48
+ ]
49
+ doc = project_debt(history)
50
+ self.assertFalse(doc["has_enough_history"])
51
+ for p in doc["projections"]:
52
+ self.assertEqual(p["status"], "insufficient_history")
53
+ self.assertIsNone(p["target_zero_date_p50"])
54
+ self.assertEqual(self.validate_schema(doc), [])
55
+
56
+ def test_converging_burndown_and_targets(self) -> None:
57
+ # 7 snapshots spanning 42 days, steady fix velocity
58
+ history = [
59
+ {"date": "2026-08-01", "open": 30, "new": 1, "fixed": 3, "regressed": 0, "by_severity": {"critical": 6, "high": 12}},
60
+ {"date": "2026-08-08", "open": 28, "new": 1, "fixed": 3, "regressed": 0, "by_severity": {"critical": 5, "high": 11}},
61
+ {"date": "2026-08-15", "open": 25, "new": 0, "fixed": 3, "regressed": 0, "by_severity": {"critical": 4, "high": 10}},
62
+ {"date": "2026-08-22", "open": 22, "new": 1, "fixed": 4, "regressed": 0, "by_severity": {"critical": 3, "high": 9}},
63
+ {"date": "2026-08-29", "open": 19, "new": 1, "fixed": 4, "regressed": 0, "by_severity": {"critical": 2, "high": 8}},
64
+ {"date": "2026-09-05", "open": 16, "new": 0, "fixed": 3, "regressed": 0, "by_severity": {"critical": 1, "high": 8}},
65
+ {"date": "2026-09-12", "open": 13, "new": 0, "fixed": 3, "regressed": 0, "by_severity": {"critical": 1, "high": 7}},
66
+ ]
67
+ targets = {
68
+ "critical": "2026-10-15", # Generous target
69
+ "overall": "2026-10-01", # Very tight target -> should be off_track
70
+ }
71
+
72
+ doc = project_debt(history, targets=targets, today="2026-09-12")
73
+ errors = self.validate_schema(doc)
74
+ self.assertEqual(errors, [], f"Schema validation failed: {errors}")
75
+ self.assertTrue(doc["has_enough_history"])
76
+
77
+ # Check rates
78
+ self.assertGreater(doc["rates"]["fix_rate_per_week"], 2.0)
79
+ self.assertLess(doc["rates"]["net_rate_per_week"], -1.0)
80
+
81
+ crit_proj = next(p for p in doc["projections"] if p["severity"] == "critical")
82
+ self.assertEqual(crit_proj["status"], "converging")
83
+ self.assertIsNotNone(crit_proj["target_zero_date_p50"])
84
+ # Dates must be sorted chronologically: P10 <= P50 <= P90
85
+ p10 = datetime.datetime.strptime(crit_proj["target_zero_date_p10"], "%Y-%m-%d")
86
+ p50 = datetime.datetime.strptime(crit_proj["target_zero_date_p50"], "%Y-%m-%d")
87
+ p90 = datetime.datetime.strptime(crit_proj["target_zero_date_p90"], "%Y-%m-%d")
88
+ self.assertLessEqual(p10, p50)
89
+ self.assertLessEqual(p50, p90)
90
+
91
+ # Critical target is generous -> on_track or at_risk
92
+ self.assertIn(crit_proj["target_status"], ("on_track", "at_risk"))
93
+
94
+ overall_proj = next(p for p in doc["projections"] if p["severity"] == "overall")
95
+ # 13 items open, net rate ~ -3/wk -> ~4 weeks (mid October). Target is Oct 1 -> off_track
96
+ self.assertEqual(overall_proj["target_status"], "off_track")
97
+
98
+ def test_diverging_or_flat_history(self) -> None:
99
+ # History where incoming defects exceed fixes
100
+ history = [
101
+ {"date": "2026-08-01", "open": 10, "new": 4, "fixed": 1, "regressed": 0, "by_severity": {"critical": 2, "high": 4}},
102
+ {"date": "2026-08-08", "open": 13, "new": 4, "fixed": 1, "regressed": 0, "by_severity": {"critical": 3, "high": 5}},
103
+ {"date": "2026-08-15", "open": 16, "new": 3, "fixed": 0, "regressed": 0, "by_severity": {"critical": 3, "high": 6}},
104
+ {"date": "2026-08-22", "open": 18, "new": 3, "fixed": 1, "regressed": 0, "by_severity": {"critical": 4, "high": 7}},
105
+ {"date": "2026-08-29", "open": 20, "new": 3, "fixed": 1, "regressed": 0, "by_severity": {"critical": 5, "high": 8}},
106
+ {"date": "2026-09-05", "open": 22, "new": 3, "fixed": 1, "regressed": 0, "by_severity": {"critical": 5, "high": 9}},
107
+ {"date": "2026-09-12", "open": 25, "new": 4, "fixed": 1, "regressed": 0, "by_severity": {"critical": 6, "high": 10}},
108
+ ]
109
+ doc = project_debt(history, targets={"overall": "2026-12-31"}, today="2026-09-12")
110
+ self.assertTrue(doc["has_enough_history"])
111
+ overall_proj = next(p for p in doc["projections"] if p["severity"] == "overall")
112
+ self.assertEqual(overall_proj["status"], "diverging")
113
+ self.assertIsNone(overall_proj["target_zero_date_p50"])
114
+ self.assertEqual(overall_proj["target_status"], "not_converging")
115
+
116
+ def test_what_if_scenarios(self) -> None:
117
+ history = [
118
+ {"date": "2026-08-01", "open": 20, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 4, "high": 8}},
119
+ {"date": "2026-08-08", "open": 19, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 4, "high": 7}},
120
+ {"date": "2026-08-15", "open": 18, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 3, "high": 7}},
121
+ {"date": "2026-08-22", "open": 17, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 3, "high": 6}},
122
+ {"date": "2026-08-29", "open": 16, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 6}},
123
+ {"date": "2026-09-05", "open": 15, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 5}},
124
+ {"date": "2026-09-12", "open": 14, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 1, "high": 5}},
125
+ ]
126
+ what_ifs = [
127
+ {"id": "add_dev", "fix_rate_delta": 3.0, "description": "Add 1 developer (+3 fixes/wk)"}
128
+ ]
129
+ doc = project_debt(history, what_if_modifiers=what_ifs, today="2026-09-12")
130
+ self.assertEqual(len(doc["scenarios"]), 2)
131
+ sc = doc["scenarios"][1]
132
+ self.assertEqual(sc["scenario_id"], "add_dev")
133
+ self.assertIn("Estimated ~", sc["projected_date_range"])
134
+
135
+ def test_markdown_and_html_rendering(self) -> None:
136
+ history = [
137
+ {"date": "2026-08-01", "open": 10, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 4}},
138
+ {"date": "2026-08-08", "open": 9, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 2, "high": 3}},
139
+ {"date": "2026-08-15", "open": 8, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 1, "high": 3}},
140
+ {"date": "2026-08-22", "open": 7, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 1, "high": 2}},
141
+ {"date": "2026-08-29", "open": 6, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 1, "high": 1}},
142
+ {"date": "2026-09-05", "open": 5, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 0, "high": 1}},
143
+ {"date": "2026-09-12", "open": 4, "new": 1, "fixed": 2, "regressed": 0, "by_severity": {"critical": 0, "high": 0}},
144
+ ]
145
+ doc = project_debt(history, targets={"critical": "2026-10-01"})
146
+ md_en = "\n".join(render_forecast_md(doc, "en"))
147
+ self.assertIn("Outlook & Debt Forecast", md_en)
148
+ self.assertIn("Arrival Rate", md_en)
149
+
150
+ md_ja = "\n".join(render_forecast_md(doc, "ja"))
151
+ self.assertIn("見通しとセキュリティ負債予測", md_ja)
152
+ self.assertIn("目標達成予測", md_ja)
153
+
154
+ html_card = render_forecast_html(doc)
155
+ self.assertIn("Outlook &amp; Debt Forecast", html_card)
156
+ self.assertIn("Arrival Rate", html_card)
157
+
158
+
159
+ if __name__ == "__main__":
160
+ unittest.main()
@@ -255,6 +255,19 @@ async fn f(pool: &PgPool, id: &str) {
255
255
  self.assertEqual(raw, {4, 5})
256
256
 
257
257
  # --- Medium: wrong output ----------------------------------------------------------
258
+ def test_jpa_entity_is_not_reported_as_typeorm(self) -> None:
259
+ with tempfile.TemporaryDirectory() as td:
260
+ root = Path(td)
261
+ (root / "Account.java").write_text(
262
+ 'import javax.persistence.Entity;\n@Entity\npublic class Account { Long id; String role; }\n',
263
+ encoding="utf-8")
264
+ data_access = detect(root, self.config)["data_access"]
265
+ self.assertIn("Hibernate/JPA", data_access)
266
+ self.assertNotIn("TypeORM (Node/TS)", data_access)
267
+ (root / "user.entity.ts").write_text(
268
+ "import { Entity, Column } from 'typeorm';\n@Entity()\nexport class User {}\n", encoding="utf-8")
269
+ self.assertIn("TypeORM (Node/TS)", detect(root, self.config)["data_access"])
270
+
258
271
  def test_go_stack_label_ignores_gin_substring(self) -> None:
259
272
  routes, _ = self.scan({"main.go": '''package main
260
273
  import "github.com/labstack/echo/v4"
@@ -0,0 +1,213 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "lsa/api-inventory-diff.schema.json",
4
+ "title": "Komagu API Inventory Diff",
5
+ "type": "object",
6
+ "required": [
7
+ "version",
8
+ "generated_at",
9
+ "sources",
10
+ "summary",
11
+ "shadow_apis",
12
+ "zombie_apis",
13
+ "documented_missing_code",
14
+ "dormant_code"
15
+ ],
16
+ "properties": {
17
+ "version": {
18
+ "type": "integer",
19
+ "enum": [
20
+ 1
21
+ ]
22
+ },
23
+ "generated_at": {
24
+ "type": "string"
25
+ },
26
+ "sources": {
27
+ "type": "object",
28
+ "required": [
29
+ "routes_file",
30
+ "openapi_file"
31
+ ],
32
+ "properties": {
33
+ "routes_file": {
34
+ "type": [
35
+ "string",
36
+ "null"
37
+ ]
38
+ },
39
+ "openapi_file": {
40
+ "type": [
41
+ "string",
42
+ "null"
43
+ ]
44
+ },
45
+ "traffic_log_file": {
46
+ "type": [
47
+ "string",
48
+ "null"
49
+ ]
50
+ }
51
+ }
52
+ },
53
+ "summary": {
54
+ "type": "object",
55
+ "required": [
56
+ "total_code_routes",
57
+ "total_spec_endpoints",
58
+ "total_traffic_endpoints",
59
+ "shadow_count",
60
+ "zombie_count",
61
+ "missing_code_count",
62
+ "dormant_count"
63
+ ],
64
+ "properties": {
65
+ "total_code_routes": {
66
+ "type": "integer"
67
+ },
68
+ "total_spec_endpoints": {
69
+ "type": "integer"
70
+ },
71
+ "total_traffic_endpoints": {
72
+ "type": "integer"
73
+ },
74
+ "shadow_count": {
75
+ "type": "integer"
76
+ },
77
+ "zombie_count": {
78
+ "type": "integer"
79
+ },
80
+ "missing_code_count": {
81
+ "type": "integer"
82
+ },
83
+ "dormant_count": {
84
+ "type": "integer"
85
+ }
86
+ }
87
+ },
88
+ "shadow_apis": {
89
+ "type": "array",
90
+ "description": "Endpoints present in code or seen in traffic, but absent from OpenAPI specification.",
91
+ "items": {
92
+ "type": "object",
93
+ "required": [
94
+ "method",
95
+ "path",
96
+ "normalized",
97
+ "discovered_in"
98
+ ],
99
+ "properties": {
100
+ "method": {
101
+ "type": "string"
102
+ },
103
+ "path": {
104
+ "type": "string"
105
+ },
106
+ "normalized": {
107
+ "type": "string"
108
+ },
109
+ "discovered_in": {
110
+ "type": "string",
111
+ "enum": [
112
+ "code_only",
113
+ "traffic_only",
114
+ "code_and_traffic"
115
+ ]
116
+ },
117
+ "location": {
118
+ "type": "string"
119
+ },
120
+ "traffic_hits": {
121
+ "type": "integer"
122
+ }
123
+ }
124
+ }
125
+ },
126
+ "zombie_apis": {
127
+ "type": "array",
128
+ "description": "Endpoints marked as deprecated in OpenAPI spec, but still active/receiving live traffic.",
129
+ "items": {
130
+ "type": "object",
131
+ "required": [
132
+ "method",
133
+ "path",
134
+ "normalized",
135
+ "traffic_hits"
136
+ ],
137
+ "properties": {
138
+ "method": {
139
+ "type": "string"
140
+ },
141
+ "path": {
142
+ "type": "string"
143
+ },
144
+ "normalized": {
145
+ "type": "string"
146
+ },
147
+ "location": {
148
+ "type": "string"
149
+ },
150
+ "traffic_hits": {
151
+ "type": "integer"
152
+ },
153
+ "last_seen": {
154
+ "type": "string"
155
+ }
156
+ }
157
+ }
158
+ },
159
+ "documented_missing_code": {
160
+ "type": "array",
161
+ "description": "Endpoints declared in OpenAPI spec but not found implemented in code inventory.",
162
+ "items": {
163
+ "type": "object",
164
+ "required": [
165
+ "method",
166
+ "path",
167
+ "normalized"
168
+ ],
169
+ "properties": {
170
+ "method": {
171
+ "type": "string"
172
+ },
173
+ "path": {
174
+ "type": "string"
175
+ },
176
+ "normalized": {
177
+ "type": "string"
178
+ },
179
+ "summary": {
180
+ "type": "string"
181
+ }
182
+ }
183
+ }
184
+ },
185
+ "dormant_code": {
186
+ "type": "array",
187
+ "description": "Endpoints implemented in code and documented, but never seen in traffic log.",
188
+ "items": {
189
+ "type": "object",
190
+ "required": [
191
+ "method",
192
+ "path",
193
+ "normalized",
194
+ "location"
195
+ ],
196
+ "properties": {
197
+ "method": {
198
+ "type": "string"
199
+ },
200
+ "path": {
201
+ "type": "string"
202
+ },
203
+ "normalized": {
204
+ "type": "string"
205
+ },
206
+ "location": {
207
+ "type": "string"
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
@@ -0,0 +1,181 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "lsa/attack-chain.schema.json",
4
+ "title": "Komagu attack chain collection",
5
+ "type": "object",
6
+ "required": [
7
+ "version",
8
+ "chains",
9
+ "summary"
10
+ ],
11
+ "properties": {
12
+ "version": {
13
+ "type": "integer",
14
+ "enum": [
15
+ 1
16
+ ]
17
+ },
18
+ "generated_at": {
19
+ "type": "string"
20
+ },
21
+ "chains": {
22
+ "type": "array",
23
+ "items": {
24
+ "type": "object",
25
+ "required": [
26
+ "chain_id",
27
+ "title",
28
+ "entry",
29
+ "steps",
30
+ "outcome",
31
+ "business_impact",
32
+ "severity",
33
+ "confidence",
34
+ "breaking_fixes"
35
+ ],
36
+ "properties": {
37
+ "chain_id": {
38
+ "type": "string",
39
+ "pattern": "^chain-[0-9]{3,}$"
40
+ },
41
+ "title": {
42
+ "type": "string"
43
+ },
44
+ "entry": {
45
+ "type": "string",
46
+ "enum": [
47
+ "anonymous",
48
+ "authenticated_user",
49
+ "tenant_admin"
50
+ ]
51
+ },
52
+ "steps": {
53
+ "type": "array",
54
+ "items": {
55
+ "type": "object",
56
+ "required": [
57
+ "step_index",
58
+ "finding_id",
59
+ "role_in_chain",
60
+ "requires",
61
+ "gains"
62
+ ],
63
+ "properties": {
64
+ "step_index": {
65
+ "type": "integer"
66
+ },
67
+ "finding_id": {
68
+ "type": "string"
69
+ },
70
+ "category": {
71
+ "type": "string"
72
+ },
73
+ "subtype": {
74
+ "type": "string"
75
+ },
76
+ "location": {
77
+ "type": "string"
78
+ },
79
+ "endpoint": {
80
+ "type": "string"
81
+ },
82
+ "role_in_chain": {
83
+ "type": "string",
84
+ "enum": [
85
+ "entry_point",
86
+ "privilege_gain",
87
+ "access",
88
+ "impact"
89
+ ]
90
+ },
91
+ "requires": {
92
+ "type": "string"
93
+ },
94
+ "gains": {
95
+ "type": "array",
96
+ "items": {
97
+ "type": "string"
98
+ }
99
+ },
100
+ "explanation": {
101
+ "type": "string"
102
+ }
103
+ }
104
+ }
105
+ },
106
+ "outcome": {
107
+ "type": "string"
108
+ },
109
+ "business_impact": {
110
+ "type": "string"
111
+ },
112
+ "severity": {
113
+ "type": "string",
114
+ "enum": [
115
+ "critical",
116
+ "high",
117
+ "medium",
118
+ "low"
119
+ ]
120
+ },
121
+ "confidence": {
122
+ "type": "string",
123
+ "enum": [
124
+ "confirmed",
125
+ "likely",
126
+ "needs_review"
127
+ ]
128
+ },
129
+ "breaking_fixes": {
130
+ "type": "array",
131
+ "items": {
132
+ "type": "string"
133
+ }
134
+ },
135
+ "narrative": {
136
+ "type": "string"
137
+ }
138
+ }
139
+ }
140
+ },
141
+ "summary": {
142
+ "type": "object",
143
+ "required": [
144
+ "total_chains",
145
+ "top_breaking_fixes"
146
+ ],
147
+ "properties": {
148
+ "total_chains": {
149
+ "type": "integer"
150
+ },
151
+ "by_severity": {
152
+ "type": "object"
153
+ },
154
+ "top_breaking_fixes": {
155
+ "type": "array",
156
+ "items": {
157
+ "type": "object",
158
+ "required": [
159
+ "finding_id",
160
+ "chains_broken"
161
+ ],
162
+ "properties": {
163
+ "finding_id": {
164
+ "type": "string"
165
+ },
166
+ "category": {
167
+ "type": "string"
168
+ },
169
+ "location": {
170
+ "type": "string"
171
+ },
172
+ "chains_broken": {
173
+ "type": "integer"
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }