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.
- complyroll/__init__.py +31 -0
- complyroll/__main__.py +6 -0
- complyroll/adapters/__init__.py +42 -0
- complyroll/adapters/base.py +172 -0
- complyroll/adapters/safeio.py +180 -0
- complyroll/adapters/stig.py +841 -0
- complyroll/cli.py +318 -0
- complyroll/compat/__init__.py +1 -0
- complyroll/compat/stigroll.py +370 -0
- complyroll/correlation/__init__.py +200 -0
- complyroll/data/README.md +34 -0
- complyroll/data/fedramp-accepted-vulnerability-info-schema-2026-06-24.json +31 -0
- complyroll/data/fedramp-common-definitions-schema-2026-06-24.json +268 -0
- complyroll/data/fedramp-consolidated-rules.json +12296 -0
- complyroll/data/fedramp-historical-ver-activity-schema-2026-06-24.json +41 -0
- complyroll/data/fedramp-rules-source.json +14 -0
- complyroll/data/fedramp-ver-schemas-source.json +37 -0
- complyroll/data/fedramp-vulnerability-detail-report-schema-2026-06-24.json +31 -0
- complyroll/models.py +478 -0
- complyroll/policy/__init__.py +61 -0
- complyroll/policy/rules.py +592 -0
- complyroll/policy/source.py +248 -0
- complyroll/py.typed +0 -0
- complyroll/reports/__init__.py +59 -0
- complyroll/reports/evaluations.py +468 -0
- complyroll/reports/vdt.py +1347 -0
- complyroll/schemas/__init__.py +57 -0
- complyroll/schemas/source.py +380 -0
- complyroll/schemas/validation.py +215 -0
- complyroll/store/__init__.py +33 -0
- complyroll/store/sqlite.py +1088 -0
- complyroll-0.2.0a0.dist-info/METADATA +254 -0
- complyroll-0.2.0a0.dist-info/RECORD +38 -0
- complyroll-0.2.0a0.dist-info/WHEEL +5 -0
- complyroll-0.2.0a0.dist-info/entry_points.txt +3 -0
- complyroll-0.2.0a0.dist-info/licenses/LICENSE +202 -0
- complyroll-0.2.0a0.dist-info/licenses/NOTICE +35 -0
- 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,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
|