versionsec 0.7.1__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.
- forgeguard/__init__.py +78 -0
- versionsec/__init__.py +9 -0
- versionsec/__main__.py +4 -0
- versionsec/advisories/__init__.py +1 -0
- versionsec/advisories/catalog/forgejo.json +38 -0
- versionsec/advisories/catalog/gitea.json +68 -0
- versionsec/advisories/evaluator.py +83 -0
- versionsec/advisories/models.py +53 -0
- versionsec/assessment.py +66 -0
- versionsec/checks.py +469 -0
- versionsec/cli.py +375 -0
- versionsec/client.py +160 -0
- versionsec/config_review.py +157 -0
- versionsec/engine.py +333 -0
- versionsec/exporters/__init__.py +1 -0
- versionsec/exporters/json.py +12 -0
- versionsec/exporters/markdown.py +40 -0
- versionsec/exporters/sarif.py +68 -0
- versionsec/models.py +79 -0
- versionsec/output.py +72 -0
- versionsec/policy.py +25 -0
- versionsec/providers/__init__.py +1 -0
- versionsec/providers/base.py +92 -0
- versionsec/providers/forgejo.py +29 -0
- versionsec/providers/gitea.py +21 -0
- versionsec/providers/registry.py +14 -0
- versionsec/py.typed +1 -0
- versionsec/report.py +214 -0
- versionsec/runner_review.py +440 -0
- versionsec/safety.py +15 -0
- versionsec/schemas/OASIS_NOTICE.md +79 -0
- versionsec/schemas/assessment-v1.json +493 -0
- versionsec/schemas/config-snapshot-v1.json +230 -0
- versionsec/schemas/runner-snapshot-v1.json +155 -0
- versionsec/schemas/sarif-2.1.0.json +3389 -0
- versionsec/scoring.py +96 -0
- versionsec/urls.py +54 -0
- versionsec/version.py +14 -0
- versionsec-0.7.1.dist-info/METADATA +142 -0
- versionsec-0.7.1.dist-info/RECORD +44 -0
- versionsec-0.7.1.dist-info/WHEEL +5 -0
- versionsec-0.7.1.dist-info/entry_points.txt +3 -0
- versionsec-0.7.1.dist-info/licenses/LICENSE +184 -0
- versionsec-0.7.1.dist-info/top_level.txt +2 -0
forgeguard/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Compatibility shim: ForgeGuard was renamed to VersionSec in 0.7.0.
|
|
2
|
+
|
|
3
|
+
``import forgeguard`` and ``import forgeguard.cli`` keep working and resolve to the
|
|
4
|
+
canonical ``versionsec`` implementation. There is no second implementation here: every
|
|
5
|
+
name is an alias of the canonical module object, so the two import paths cannot drift.
|
|
6
|
+
|
|
7
|
+
Canonical from 0.7.0 onward::
|
|
8
|
+
|
|
9
|
+
python -m pip install versionsec
|
|
10
|
+
import versionsec
|
|
11
|
+
|
|
12
|
+
The legacy path stays quiet on purpose - it emits no warning to stdout or stderr, so
|
|
13
|
+
existing automation does not break. See MIGRATION.md.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
import versionsec as _versionsec
|
|
22
|
+
|
|
23
|
+
__version__ = _versionsec.__version__
|
|
24
|
+
__all__ = list(getattr(_versionsec, "__all__", []))
|
|
25
|
+
|
|
26
|
+
_SUBMODULES = (
|
|
27
|
+
"advisories",
|
|
28
|
+
"advisories.evaluator",
|
|
29
|
+
"advisories.models",
|
|
30
|
+
"assessment",
|
|
31
|
+
"checks",
|
|
32
|
+
"cli",
|
|
33
|
+
"client",
|
|
34
|
+
"config_review",
|
|
35
|
+
"engine",
|
|
36
|
+
"exporters",
|
|
37
|
+
"exporters.json",
|
|
38
|
+
"exporters.markdown",
|
|
39
|
+
"exporters.sarif",
|
|
40
|
+
"models",
|
|
41
|
+
"output",
|
|
42
|
+
"policy",
|
|
43
|
+
"providers",
|
|
44
|
+
"providers.base",
|
|
45
|
+
"providers.forgejo",
|
|
46
|
+
"providers.gitea",
|
|
47
|
+
"providers.registry",
|
|
48
|
+
"report",
|
|
49
|
+
"runner_review",
|
|
50
|
+
"safety",
|
|
51
|
+
"scoring",
|
|
52
|
+
"urls",
|
|
53
|
+
"version",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Register each canonical submodule under the legacy name as well, so that the
|
|
57
|
+
# statement form ``import forgeguard.cli`` resolves without a second implementation.
|
|
58
|
+
for _name in _SUBMODULES:
|
|
59
|
+
try:
|
|
60
|
+
_module = importlib.import_module(f"versionsec.{_name}")
|
|
61
|
+
except ModuleNotFoundError: # pragma: no cover - defensive
|
|
62
|
+
continue
|
|
63
|
+
sys.modules[f"{__name__}.{_name}"] = _module
|
|
64
|
+
if "." not in _name:
|
|
65
|
+
globals()[_name] = _module
|
|
66
|
+
del _name, _module
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def __getattr__(name: str):
|
|
70
|
+
"""Any remaining attribute resolves against the canonical package."""
|
|
71
|
+
try:
|
|
72
|
+
return getattr(_versionsec, name)
|
|
73
|
+
except AttributeError as exc:
|
|
74
|
+
raise AttributeError(name) from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def __dir__():
|
|
78
|
+
return sorted(set(dir(_versionsec)) | {n for n in _SUBMODULES if "." not in n})
|
versionsec/__init__.py
ADDED
versionsec/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Version-only advisory evaluation; never performs network access."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026-09-11.1",
|
|
3
|
+
"verified_at": "2026-09-11",
|
|
4
|
+
"records": [
|
|
5
|
+
{
|
|
6
|
+
"id": "FG-FJ-TEMPLATE-20260910",
|
|
7
|
+
"product": "forgejo",
|
|
8
|
+
"title": "Template initialization security update version posture",
|
|
9
|
+
"sources": [
|
|
10
|
+
"https://codeberg.org/forgejo/forgejo/src/branch/forgejo/release-notes-published/15.0.8.md",
|
|
11
|
+
"https://codeberg.org/forgejo/forgejo/src/branch/forgejo/release-notes-published/16.0.4.md"
|
|
12
|
+
],
|
|
13
|
+
"source_sha256": [
|
|
14
|
+
"d0ca357d547734c72b2956ba5fd36784ce3a37f1c2fb3c1c28cf870ff25dd7d4",
|
|
15
|
+
"5769d9d511c035f29e0c345718f4a2cc1f9567f9f93895005e038cafd2c4b99a"
|
|
16
|
+
],
|
|
17
|
+
"verified_at": "2026-09-11",
|
|
18
|
+
"retrieval_url": "https://codeberg.org/forgejo/forgejo/raw/branch/forgejo/release-notes-published/15.0.8.md ; https://codeberg.org/forgejo/forgejo/raw/branch/forgejo/release-notes-published/16.0.4.md",
|
|
19
|
+
"selected_record_id": "forgejo-release-notes-15.0.8+16.0.4",
|
|
20
|
+
"selected_record_location": "Two exact release-note documents; bytes frozen and re-verified unchanged on 2026-09-11. Establishes fixed releases only; no affected introduction boundary is asserted.",
|
|
21
|
+
"affected": [],
|
|
22
|
+
"fixed": [
|
|
23
|
+
{
|
|
24
|
+
"lower": "15.0.8",
|
|
25
|
+
"upper": "15.0.8"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"lower": "16.0.4",
|
|
29
|
+
"upper": "16.0.4"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"severity": "critical",
|
|
33
|
+
"severity_source": "https://codeberg.org/forgejo/forgejo/src/branch/forgejo/release-notes-published/16.0.4.md",
|
|
34
|
+
"limitations": "Release notes prove fixes in these exact releases. They do not establish an introduction boundary; all other versions remain undetermined. No CVE ID is assigned by this catalog.",
|
|
35
|
+
"applicability": "Unmodified upstream release; no feature activation or exploitability claim."
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "2026-09-11.1",
|
|
3
|
+
"verified_at": "2026-09-11",
|
|
4
|
+
"records": [
|
|
5
|
+
{
|
|
6
|
+
"id": "FG-CVE-27771",
|
|
7
|
+
"product": "gitea",
|
|
8
|
+
"title": "CVE-2026-27771 version posture",
|
|
9
|
+
"sources": [
|
|
10
|
+
"https://github.com/go-gitea/gitea/security/advisories/GHSA-8qw8-rq86-9pc2"
|
|
11
|
+
],
|
|
12
|
+
"source_sha256": [
|
|
13
|
+
"09a541436af61a369d038203d01c9bd1bb4c903b1bc6ab19f7694da62f4f52f5"
|
|
14
|
+
],
|
|
15
|
+
"verified_at": "2026-09-11",
|
|
16
|
+
"retrieval_url": "https://api.github.com/repos/go-gitea/gitea/security-advisories/GHSA-8qw8-rq86-9pc2",
|
|
17
|
+
"selected_record_id": "GHSA-8qw8-rq86-9pc2",
|
|
18
|
+
"selected_record_location": "Single advisory record endpoint; frozen exact bytes, not a collection listing.",
|
|
19
|
+
"affected": [
|
|
20
|
+
{
|
|
21
|
+
"lower": "0.0.0",
|
|
22
|
+
"upper": "1.26.1"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"fixed": [
|
|
26
|
+
{
|
|
27
|
+
"lower": "1.26.2",
|
|
28
|
+
"upper": "1.27.3"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"severity": "high",
|
|
32
|
+
"severity_source": "https://github.com/go-gitea/gitea/security/advisories/GHSA-8qw8-rq86-9pc2",
|
|
33
|
+
"limitations": "No custom vendor backports or future releases inferred.",
|
|
34
|
+
"applicability": "Unmodified upstream version; version posture only."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "FG-CVE-78433",
|
|
38
|
+
"product": "gitea",
|
|
39
|
+
"title": "CVE-2026-78433 version posture",
|
|
40
|
+
"sources": [
|
|
41
|
+
"https://github.com/go-gitea/gitea/security/advisories/GHSA-frpv-2xgv-wxpq"
|
|
42
|
+
],
|
|
43
|
+
"source_sha256": [
|
|
44
|
+
"c8e6cec99c7f6fc41a4b18b6cb0cff9e2c86561dec7952d63176a52aee7a5110"
|
|
45
|
+
],
|
|
46
|
+
"verified_at": "2026-09-11",
|
|
47
|
+
"retrieval_url": "https://api.github.com/repos/go-gitea/gitea/security-advisories/GHSA-frpv-2xgv-wxpq",
|
|
48
|
+
"selected_record_id": "GHSA-frpv-2xgv-wxpq",
|
|
49
|
+
"selected_record_location": "Single advisory record endpoint for this GHSA, not the security-advisories collection listing whose bytes and hash differ and drift.",
|
|
50
|
+
"affected": [
|
|
51
|
+
{
|
|
52
|
+
"lower": "1.26.0",
|
|
53
|
+
"upper": "1.27.2"
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"fixed": [
|
|
57
|
+
{
|
|
58
|
+
"lower": "1.27.3",
|
|
59
|
+
"upper": "1.27.3"
|
|
60
|
+
}
|
|
61
|
+
],
|
|
62
|
+
"severity": "medium",
|
|
63
|
+
"severity_source": "https://github.com/go-gitea/gitea/security/advisories/GHSA-frpv-2xgv-wxpq",
|
|
64
|
+
"limitations": "No custom vendor backports or future releases inferred.",
|
|
65
|
+
"applicability": "Unmodified upstream version; version posture only."
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
|
|
5
|
+
from ..assessment import EvidenceFinding, Identity
|
|
6
|
+
from ..models import EvidenceState, Severity, Status
|
|
7
|
+
from ..providers.base import release_version
|
|
8
|
+
from .models import Advisory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def catalog(product: str) -> tuple[list[Advisory], dict]:
|
|
12
|
+
if product not in {"gitea", "forgejo"}:
|
|
13
|
+
return [], {"version": "2026-09-10.1", "coverage": "unknown product"}
|
|
14
|
+
data = (
|
|
15
|
+
files("versionsec")
|
|
16
|
+
.joinpath("advisories", "catalog", product + ".json")
|
|
17
|
+
.read_bytes()
|
|
18
|
+
)
|
|
19
|
+
payload = json.loads(data)
|
|
20
|
+
records = [Advisory.model_validate(x) for x in payload["records"]]
|
|
21
|
+
if any(r.product != product for r in records) or len(
|
|
22
|
+
{r.id for r in records}
|
|
23
|
+
) != len(records):
|
|
24
|
+
raise ValueError("Invalid provider catalog")
|
|
25
|
+
return records, {
|
|
26
|
+
"version": payload["version"],
|
|
27
|
+
"verified_at": payload["verified_at"],
|
|
28
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
29
|
+
"coverage": "Curated records only; absence is not evidence of safety.",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def evaluate(record: Advisory, identity: Identity) -> EvidenceFinding:
|
|
34
|
+
version = release_version(identity.normalized_version)
|
|
35
|
+
outcome = "undetermined"
|
|
36
|
+
reason = "Product/version conflict, unsupported syntax, or version outside catalog evidence."
|
|
37
|
+
if (
|
|
38
|
+
identity.declared_product == record.product
|
|
39
|
+
and not identity.product_conflict
|
|
40
|
+
and not identity.version_conflict
|
|
41
|
+
and version is not None
|
|
42
|
+
and not record.conditions
|
|
43
|
+
):
|
|
44
|
+
if any(x.contains(version) for x in record.affected):
|
|
45
|
+
outcome = "affected"
|
|
46
|
+
elif any(x.contains(version) for x in record.fixed):
|
|
47
|
+
outcome = "fixed"
|
|
48
|
+
if outcome != "undetermined":
|
|
49
|
+
reason = (
|
|
50
|
+
"Version is within the catalog's explicit " + outcome + " interval."
|
|
51
|
+
)
|
|
52
|
+
return EvidenceFinding(
|
|
53
|
+
id=record.id,
|
|
54
|
+
title=record.title,
|
|
55
|
+
scope="version",
|
|
56
|
+
source="catalog:" + record.product,
|
|
57
|
+
observed={
|
|
58
|
+
"version": identity.normalized_version,
|
|
59
|
+
"record_version": record.record_version,
|
|
60
|
+
"outcome": outcome,
|
|
61
|
+
},
|
|
62
|
+
evidence={"outcome": outcome},
|
|
63
|
+
expected="Version within the explicit fixed range for this advisory.",
|
|
64
|
+
status=Status.FAIL
|
|
65
|
+
if outcome == "affected"
|
|
66
|
+
else Status.PASS
|
|
67
|
+
if outcome == "fixed"
|
|
68
|
+
else Status.INFO,
|
|
69
|
+
severity=Severity(record.severity) if outcome == "affected" else Severity.info,
|
|
70
|
+
evidence_state=EvidenceState.INDETERMINATE
|
|
71
|
+
if outcome == "undetermined"
|
|
72
|
+
else EvidenceState.ASSESSED,
|
|
73
|
+
applicability="undetermined" if outcome == "undetermined" else "applicable",
|
|
74
|
+
reason=reason,
|
|
75
|
+
rationale=reason
|
|
76
|
+
+ " Version assessment does not prove exploitability. "
|
|
77
|
+
+ record.limitations,
|
|
78
|
+
remediation="Review the upstream advisory and upgrade within a supported line."
|
|
79
|
+
if outcome != "fixed"
|
|
80
|
+
else "",
|
|
81
|
+
references=sorted(record.sources),
|
|
82
|
+
penalty_group="version-advisories",
|
|
83
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
4
|
+
|
|
5
|
+
from ..providers.base import release_version
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Interval(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="forbid")
|
|
10
|
+
lower: str
|
|
11
|
+
upper: str
|
|
12
|
+
|
|
13
|
+
@model_validator(mode="after")
|
|
14
|
+
def validate_bounds(self):
|
|
15
|
+
low, high = release_version(self.lower), release_version(self.upper)
|
|
16
|
+
if low is None or high is None or low > high:
|
|
17
|
+
raise ValueError("Invalid inclusive advisory interval")
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def contains(self, version: tuple[int, int, int]) -> bool:
|
|
21
|
+
return release_version(self.lower) <= version <= release_version(self.upper)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Advisory(BaseModel):
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
id: str
|
|
27
|
+
product: Literal["gitea", "forgejo"]
|
|
28
|
+
record_version: int = 1
|
|
29
|
+
title: str
|
|
30
|
+
sources: list[str]
|
|
31
|
+
source_sha256: list[str]
|
|
32
|
+
verified_at: str
|
|
33
|
+
affected: list[Interval]
|
|
34
|
+
fixed: list[Interval]
|
|
35
|
+
severity: Literal["critical", "high", "medium", "low", "info"]
|
|
36
|
+
severity_source: str
|
|
37
|
+
limitations: str
|
|
38
|
+
applicability: str
|
|
39
|
+
retrieval_url: str = ""
|
|
40
|
+
selected_record_id: str = ""
|
|
41
|
+
selected_record_location: str = ""
|
|
42
|
+
conditions: dict[str, bool] = Field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
@model_validator(mode="after")
|
|
45
|
+
def disjoint_ranges(self):
|
|
46
|
+
for a in self.affected:
|
|
47
|
+
for b in self.fixed:
|
|
48
|
+
if not (
|
|
49
|
+
release_version(a.upper) < release_version(b.lower)
|
|
50
|
+
or release_version(b.upper) < release_version(a.lower)
|
|
51
|
+
):
|
|
52
|
+
raise ValueError("Overlapping affected/fixed intervals")
|
|
53
|
+
return self
|
versionsec/assessment.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from .models import Finding, ScanResult
|
|
8
|
+
|
|
9
|
+
Intent = Literal["public", "private", "unspecified"]
|
|
10
|
+
Profile = Literal["minimal", "standard", "extended"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EvidenceFinding(Finding):
|
|
14
|
+
check_version: str = "1"
|
|
15
|
+
scope: Literal["http", "version", "operator-snapshot"]
|
|
16
|
+
source: str
|
|
17
|
+
observed: dict = Field(default_factory=dict)
|
|
18
|
+
expected: str
|
|
19
|
+
applicability: Literal[
|
|
20
|
+
"applicable", "undetermined", "not_applicable", "skipped_by_profile"
|
|
21
|
+
]
|
|
22
|
+
reason: str
|
|
23
|
+
penalty_group: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Identity(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid")
|
|
28
|
+
declared_product: str | None
|
|
29
|
+
declared_version_marker: str | None = None
|
|
30
|
+
observed_product_marker: str | None
|
|
31
|
+
product_source: str
|
|
32
|
+
declared_version: str | None
|
|
33
|
+
observed_version: str | None
|
|
34
|
+
normalized_version: str | None
|
|
35
|
+
product_conflict: bool
|
|
36
|
+
version_conflict: bool
|
|
37
|
+
support: Literal["qualified", "unsupported", "unknown"]
|
|
38
|
+
provider_revision: str | None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Assessment(ScanResult):
|
|
42
|
+
schema_id: Literal["forgeguard.assessment.v1"] = "forgeguard.assessment.v1"
|
|
43
|
+
profile: str
|
|
44
|
+
profile_version: Literal["1"] = "1"
|
|
45
|
+
policy: Intent
|
|
46
|
+
policy_version: Literal["1"] = "1"
|
|
47
|
+
scoring_version: Literal["2"] = "2"
|
|
48
|
+
identity: Identity
|
|
49
|
+
catalog: dict
|
|
50
|
+
findings: list[EvidenceFinding]
|
|
51
|
+
skipped_checks: list[str]
|
|
52
|
+
request_count: int
|
|
53
|
+
limitations: list[str]
|
|
54
|
+
run: dict[str, str] = Field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
def normalized(self) -> dict:
|
|
57
|
+
result = self.model_dump(mode="json")
|
|
58
|
+
result["run"] = {
|
|
59
|
+
k: v
|
|
60
|
+
for k, v in result["run"].items()
|
|
61
|
+
if k not in {"timestamp", "reviewed_at"}
|
|
62
|
+
}
|
|
63
|
+
if not result["run"]:
|
|
64
|
+
result.pop("run")
|
|
65
|
+
result.pop("scan_id")
|
|
66
|
+
return result
|