complyroll 0.2.0a0__py3-none-any.whl

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 (38) hide show
  1. complyroll/__init__.py +31 -0
  2. complyroll/__main__.py +6 -0
  3. complyroll/adapters/__init__.py +42 -0
  4. complyroll/adapters/base.py +172 -0
  5. complyroll/adapters/safeio.py +180 -0
  6. complyroll/adapters/stig.py +841 -0
  7. complyroll/cli.py +318 -0
  8. complyroll/compat/__init__.py +1 -0
  9. complyroll/compat/stigroll.py +370 -0
  10. complyroll/correlation/__init__.py +200 -0
  11. complyroll/data/README.md +34 -0
  12. complyroll/data/fedramp-accepted-vulnerability-info-schema-2026-06-24.json +31 -0
  13. complyroll/data/fedramp-common-definitions-schema-2026-06-24.json +268 -0
  14. complyroll/data/fedramp-consolidated-rules.json +12296 -0
  15. complyroll/data/fedramp-historical-ver-activity-schema-2026-06-24.json +41 -0
  16. complyroll/data/fedramp-rules-source.json +14 -0
  17. complyroll/data/fedramp-ver-schemas-source.json +37 -0
  18. complyroll/data/fedramp-vulnerability-detail-report-schema-2026-06-24.json +31 -0
  19. complyroll/models.py +478 -0
  20. complyroll/policy/__init__.py +61 -0
  21. complyroll/policy/rules.py +592 -0
  22. complyroll/policy/source.py +248 -0
  23. complyroll/py.typed +0 -0
  24. complyroll/reports/__init__.py +59 -0
  25. complyroll/reports/evaluations.py +468 -0
  26. complyroll/reports/vdt.py +1347 -0
  27. complyroll/schemas/__init__.py +57 -0
  28. complyroll/schemas/source.py +380 -0
  29. complyroll/schemas/validation.py +215 -0
  30. complyroll/store/__init__.py +33 -0
  31. complyroll/store/sqlite.py +1088 -0
  32. complyroll-0.2.0a0.dist-info/METADATA +254 -0
  33. complyroll-0.2.0a0.dist-info/RECORD +38 -0
  34. complyroll-0.2.0a0.dist-info/WHEEL +5 -0
  35. complyroll-0.2.0a0.dist-info/entry_points.txt +3 -0
  36. complyroll-0.2.0a0.dist-info/licenses/LICENSE +202 -0
  37. complyroll-0.2.0a0.dist-info/licenses/NOTICE +35 -0
  38. complyroll-0.2.0a0.dist-info/top_level.txt +1 -0
complyroll/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """ComplyRoll domain package."""
2
+
3
+ from .models import (
4
+ CaseStatus,
5
+ Evaluation,
6
+ EvidenceArtifact,
7
+ Observation,
8
+ ObservationDisposition,
9
+ ObservationOrigin,
10
+ PainRating,
11
+ ResourceRef,
12
+ Sensitivity,
13
+ SourceSeverity,
14
+ VulnerabilityCase,
15
+ )
16
+
17
+ __all__ = [
18
+ "CaseStatus",
19
+ "Evaluation",
20
+ "EvidenceArtifact",
21
+ "Observation",
22
+ "ObservationDisposition",
23
+ "ObservationOrigin",
24
+ "PainRating",
25
+ "ResourceRef",
26
+ "Sensitivity",
27
+ "SourceSeverity",
28
+ "VulnerabilityCase",
29
+ ]
30
+
31
+ __version__ = "0.2.0a0"
complyroll/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Run ComplyRoll with ``python -m complyroll``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,42 @@
1
+ """Source adapters and hardened ingestion entry points."""
2
+
3
+ from .base import (
4
+ AdapterOutput,
5
+ ArtifactProvenance,
6
+ ControlMapping,
7
+ DiagnosticLevel,
8
+ IngestDiagnostic,
9
+ IngestResult,
10
+ SourceAdapter,
11
+ )
12
+ from .safeio import DEFAULT_LIMITS, IngestLimits, InputLimitError, UnsafeXmlError
13
+ from .stig import (
14
+ CciControlMap,
15
+ CciMapResult,
16
+ CklAdapter,
17
+ CklbAdapter,
18
+ XccdfAdapter,
19
+ ingest_stig_artifact,
20
+ load_cci_control_map,
21
+ )
22
+
23
+ __all__ = [
24
+ "AdapterOutput",
25
+ "ArtifactProvenance",
26
+ "CciControlMap",
27
+ "CciMapResult",
28
+ "CklAdapter",
29
+ "CklbAdapter",
30
+ "ControlMapping",
31
+ "DEFAULT_LIMITS",
32
+ "DiagnosticLevel",
33
+ "IngestDiagnostic",
34
+ "IngestLimits",
35
+ "IngestResult",
36
+ "InputLimitError",
37
+ "SourceAdapter",
38
+ "UnsafeXmlError",
39
+ "XccdfAdapter",
40
+ "ingest_stig_artifact",
41
+ "load_cci_control_map",
42
+ ]
@@ -0,0 +1,172 @@
1
+ """Adapter contracts and immutable ingestion result types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Any, Protocol
12
+
13
+ from complyroll.models import Observation
14
+
15
+
16
+ class DiagnosticLevel(str, Enum):
17
+ INFO = "info"
18
+ WARNING = "warning"
19
+ ERROR = "error"
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class IngestDiagnostic:
24
+ level: DiagnosticLevel
25
+ code: str
26
+ message: str
27
+ location: str | None = None
28
+
29
+ def to_dict(self) -> dict[str, str]:
30
+ value = {
31
+ "level": self.level.value,
32
+ "code": self.code,
33
+ "message": self.message,
34
+ }
35
+ if self.location:
36
+ value["location"] = self.location
37
+ return value
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class ArtifactProvenance:
42
+ artifact_id: str
43
+ name: str
44
+ source_path: str
45
+ digest_sha256: str
46
+ size_bytes: int
47
+ media_type: str
48
+ parser_name: str
49
+ parser_version: str
50
+ ingested_at: datetime
51
+
52
+ def __post_init__(self) -> None:
53
+ for value, field_name in (
54
+ (self.artifact_id, "artifact_id"),
55
+ (self.name, "name"),
56
+ (self.source_path, "source_path"),
57
+ (self.media_type, "media_type"),
58
+ (self.parser_name, "parser_name"),
59
+ (self.parser_version, "parser_version"),
60
+ ):
61
+ if not value or not value.strip():
62
+ raise ValueError(f"{field_name} must not be blank")
63
+ if self.size_bytes < 0:
64
+ raise ValueError("size_bytes must not be negative")
65
+ digest = self.digest_sha256.lower()
66
+ if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
67
+ raise ValueError("digest_sha256 must be a SHA-256 digest")
68
+ if self.ingested_at.tzinfo is None or self.ingested_at.utcoffset() is None:
69
+ raise ValueError("ingested_at must include a timezone")
70
+
71
+ @classmethod
72
+ def from_bytes(
73
+ cls,
74
+ *,
75
+ path: Path,
76
+ content: bytes,
77
+ media_type: str,
78
+ parser_name: str,
79
+ parser_version: str,
80
+ ingested_at: datetime,
81
+ ) -> ArtifactProvenance:
82
+ digest = hashlib.sha256(content).hexdigest()
83
+ return cls(
84
+ artifact_id=f"artifact-sha256-{digest}",
85
+ name=path.name,
86
+ source_path=str(path),
87
+ digest_sha256=digest,
88
+ size_bytes=len(content),
89
+ media_type=media_type,
90
+ parser_name=parser_name,
91
+ parser_version=parser_version,
92
+ ingested_at=ingested_at,
93
+ )
94
+
95
+ def to_dict(self) -> dict[str, Any]:
96
+ return {
97
+ "artifact_id": self.artifact_id,
98
+ "name": self.name,
99
+ "source_path": self.source_path,
100
+ "digest_sha256": self.digest_sha256,
101
+ "size_bytes": self.size_bytes,
102
+ "media_type": self.media_type,
103
+ "parser_name": self.parser_name,
104
+ "parser_version": self.parser_version,
105
+ "ingested_at": self.ingested_at.isoformat(),
106
+ }
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class ParsedDocument:
111
+ kind: str
112
+ value: Any
113
+
114
+
115
+ @dataclass(frozen=True, slots=True)
116
+ class AdapterOutput:
117
+ observations: tuple[Observation, ...]
118
+ diagnostics: tuple[IngestDiagnostic, ...] = field(default_factory=tuple)
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class IngestResult:
123
+ artifact: ArtifactProvenance | None
124
+ observations: tuple[Observation, ...]
125
+ diagnostics: tuple[IngestDiagnostic, ...]
126
+
127
+ @property
128
+ def errors(self) -> tuple[IngestDiagnostic, ...]:
129
+ return tuple(item for item in self.diagnostics if item.level is DiagnosticLevel.ERROR)
130
+
131
+ @property
132
+ def warnings(self) -> tuple[IngestDiagnostic, ...]:
133
+ return tuple(item for item in self.diagnostics if item.level is DiagnosticLevel.WARNING)
134
+
135
+ @property
136
+ def successful(self) -> bool:
137
+ return self.artifact is not None and bool(self.observations) and not self.errors
138
+
139
+ def to_dict(self) -> dict[str, Any]:
140
+ return {
141
+ "successful": self.successful,
142
+ "artifact": self.artifact.to_dict() if self.artifact else None,
143
+ "diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
144
+ "observations": [observation.to_canonical_dict() for observation in self.observations],
145
+ }
146
+
147
+ def to_canonical_json(self) -> str:
148
+ return json.dumps(self.to_dict(), ensure_ascii=False, separators=(",", ":"), sort_keys=True)
149
+
150
+
151
+ class SourceAdapter(Protocol):
152
+ """A parser that turns one already-bounded document into observations."""
153
+
154
+ name: str
155
+ version: str
156
+ media_type: str
157
+
158
+ def parse(
159
+ self,
160
+ document: ParsedDocument,
161
+ artifact: ArtifactProvenance,
162
+ *,
163
+ ingested_at: datetime,
164
+ ) -> AdapterOutput: ...
165
+
166
+
167
+ class ControlMapping(Protocol):
168
+ def controls_for(self, identifier: str) -> tuple[str, ...]: ...
169
+
170
+
171
+ class AdapterParseError(ValueError):
172
+ """Raised when a supported document is not a usable source artifact."""
@@ -0,0 +1,180 @@
1
+ """Bounded, network-free parsing helpers for untrusted source artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import stat
7
+ import xml.etree.ElementTree as ET
8
+ import xml.parsers.expat as expat
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class IngestLimits:
16
+ max_artifact_bytes: int = 32 * 1024 * 1024
17
+ max_json_depth: int = 128
18
+ max_json_nodes: int = 500_000
19
+ max_xml_depth: int = 128
20
+ max_xml_elements: int = 500_000
21
+
22
+
23
+ DEFAULT_LIMITS = IngestLimits()
24
+
25
+
26
+ class InputLimitError(ValueError):
27
+ """An input exceeded an explicit resource bound."""
28
+
29
+
30
+ class UnsafeXmlError(ValueError):
31
+ """XML contained a prohibited declaration."""
32
+
33
+
34
+ def read_bounded(path: Path, limits: IngestLimits = DEFAULT_LIMITS) -> bytes:
35
+ """Read a regular file into memory, refusing anything larger than the artifact bound."""
36
+
37
+ limit = limits.max_artifact_bytes
38
+ status = path.stat()
39
+ if not stat.S_ISREG(status.st_mode):
40
+ raise ValueError(f"artifact is not a regular file: {path}")
41
+ if status.st_size > limit:
42
+ raise InputLimitError(f"artifact is {status.st_size} bytes; maximum is {limit} bytes")
43
+
44
+ chunks: list[bytes] = []
45
+ total = 0
46
+ with path.open("rb") as handle:
47
+ while total <= limit:
48
+ chunk = handle.read(limit + 1 - total)
49
+ if not chunk:
50
+ break
51
+ chunks.append(chunk)
52
+ total += len(chunk)
53
+ if total > limit:
54
+ raise InputLimitError(f"artifact is larger than {limit} bytes; maximum is {limit} bytes")
55
+ return b"".join(chunks)
56
+
57
+
58
+ def parse_json_bounded(content: bytes, limits: IngestLimits = DEFAULT_LIMITS) -> Any:
59
+ def reject_nonstandard_constant(value: str) -> None:
60
+ raise ValueError(f"non-standard JSON constant is prohibited: {value}")
61
+
62
+ def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
63
+ value: dict[str, Any] = {}
64
+ for key, item in pairs:
65
+ if key in value:
66
+ raise ValueError(f"duplicate JSON object key is prohibited: {key}")
67
+ value[key] = item
68
+ return value
69
+
70
+ try:
71
+ value = json.loads(
72
+ content.decode("utf-8-sig"),
73
+ parse_constant=reject_nonstandard_constant,
74
+ object_pairs_hook=reject_duplicate_keys,
75
+ )
76
+ except UnicodeDecodeError as exc:
77
+ raise ValueError(f"JSON must be UTF-8: {exc}") from exc
78
+ except RecursionError as exc:
79
+ raise InputLimitError("JSON nesting exceeds the parser's safe recursion limit") from exc
80
+
81
+ nodes = 0
82
+ stack: list[tuple[Any, int]] = [(value, 1)]
83
+ while stack:
84
+ current, depth = stack.pop()
85
+ nodes += 1
86
+ if nodes > limits.max_json_nodes:
87
+ raise InputLimitError(f"JSON contains more than {limits.max_json_nodes} values")
88
+ if depth > limits.max_json_depth:
89
+ raise InputLimitError(f"JSON nesting exceeds {limits.max_json_depth} levels")
90
+ if isinstance(current, dict):
91
+ stack.extend((item, depth + 1) for item in current.values())
92
+ elif isinstance(current, list):
93
+ stack.extend((item, depth + 1) for item in current)
94
+ return value
95
+
96
+
97
+ def _qualified_name(name: str) -> str:
98
+ """Convert expat's ``uri}local`` name to ElementTree's ``{uri}local`` convention."""
99
+
100
+ return f"{{{name}" if "}" in name else name
101
+
102
+
103
+ def parse_xml_bounded(content: bytes, limits: IngestLimits = DEFAULT_LIMITS) -> ET.Element:
104
+ """Parse XML with declaration rejection and bounds enforced inside the parser.
105
+
106
+ Prohibited declarations are refused by expat's own handlers rather than by scanning the
107
+ source bytes, so the guard holds for every encoding expat auto-detects and never rejects a
108
+ document that merely quotes ``<!DOCTYPE`` inside character data or a comment.
109
+ """
110
+
111
+ builder = ET.TreeBuilder()
112
+ root: ET.Element | None = None
113
+ depth = 0
114
+ elements = 0
115
+
116
+ def reject_doctype(
117
+ name: str,
118
+ system_id: str | None,
119
+ public_id: str | None,
120
+ has_internal_subset: bool,
121
+ ) -> None:
122
+ raise UnsafeXmlError("DOCTYPE declarations are prohibited")
123
+
124
+ def reject_entity(
125
+ name: str,
126
+ is_parameter_entity: bool,
127
+ value: str | None,
128
+ base: str | None,
129
+ system_id: str | None,
130
+ public_id: str | None,
131
+ notation_name: str | None,
132
+ ) -> None:
133
+ raise UnsafeXmlError("ENTITY declarations are prohibited")
134
+
135
+ def reject_external_entity(
136
+ context: str | None,
137
+ base: str | None,
138
+ system_id: str | None,
139
+ public_id: str | None,
140
+ ) -> bool:
141
+ raise UnsafeXmlError("external entity references are prohibited")
142
+
143
+ def start_element(name: str, attributes: dict[str, str]) -> None:
144
+ nonlocal root, depth, elements
145
+ elements += 1
146
+ depth += 1
147
+ if elements > limits.max_xml_elements:
148
+ raise InputLimitError(f"XML contains more than {limits.max_xml_elements} elements")
149
+ if depth > limits.max_xml_depth:
150
+ raise InputLimitError(f"XML nesting exceeds {limits.max_xml_depth} levels")
151
+ element = builder.start(
152
+ _qualified_name(name),
153
+ {_qualified_name(key): value for key, value in attributes.items()},
154
+ )
155
+ if root is None:
156
+ root = element
157
+
158
+ def end_element(name: str) -> None:
159
+ nonlocal depth
160
+ depth -= 1
161
+ builder.end(_qualified_name(name))
162
+
163
+ parser = expat.ParserCreate(namespace_separator="}")
164
+ parser.buffer_text = True
165
+ parser.StartDoctypeDeclHandler = reject_doctype
166
+ parser.EntityDeclHandler = reject_entity
167
+ parser.ExternalEntityRefHandler = reject_external_entity
168
+ parser.StartElementHandler = start_element
169
+ parser.EndElementHandler = end_element
170
+ parser.CharacterDataHandler = builder.data
171
+
172
+ try:
173
+ parser.Parse(content, True)
174
+ builder.close()
175
+ except (expat.ExpatError, ET.ParseError) as exc:
176
+ raise ValueError(f"malformed XML: {exc}") from exc
177
+
178
+ if root is None:
179
+ raise ValueError("XML document has no root element")
180
+ return root