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.
Files changed (44) hide show
  1. forgeguard/__init__.py +78 -0
  2. versionsec/__init__.py +9 -0
  3. versionsec/__main__.py +4 -0
  4. versionsec/advisories/__init__.py +1 -0
  5. versionsec/advisories/catalog/forgejo.json +38 -0
  6. versionsec/advisories/catalog/gitea.json +68 -0
  7. versionsec/advisories/evaluator.py +83 -0
  8. versionsec/advisories/models.py +53 -0
  9. versionsec/assessment.py +66 -0
  10. versionsec/checks.py +469 -0
  11. versionsec/cli.py +375 -0
  12. versionsec/client.py +160 -0
  13. versionsec/config_review.py +157 -0
  14. versionsec/engine.py +333 -0
  15. versionsec/exporters/__init__.py +1 -0
  16. versionsec/exporters/json.py +12 -0
  17. versionsec/exporters/markdown.py +40 -0
  18. versionsec/exporters/sarif.py +68 -0
  19. versionsec/models.py +79 -0
  20. versionsec/output.py +72 -0
  21. versionsec/policy.py +25 -0
  22. versionsec/providers/__init__.py +1 -0
  23. versionsec/providers/base.py +92 -0
  24. versionsec/providers/forgejo.py +29 -0
  25. versionsec/providers/gitea.py +21 -0
  26. versionsec/providers/registry.py +14 -0
  27. versionsec/py.typed +1 -0
  28. versionsec/report.py +214 -0
  29. versionsec/runner_review.py +440 -0
  30. versionsec/safety.py +15 -0
  31. versionsec/schemas/OASIS_NOTICE.md +79 -0
  32. versionsec/schemas/assessment-v1.json +493 -0
  33. versionsec/schemas/config-snapshot-v1.json +230 -0
  34. versionsec/schemas/runner-snapshot-v1.json +155 -0
  35. versionsec/schemas/sarif-2.1.0.json +3389 -0
  36. versionsec/scoring.py +96 -0
  37. versionsec/urls.py +54 -0
  38. versionsec/version.py +14 -0
  39. versionsec-0.7.1.dist-info/METADATA +142 -0
  40. versionsec-0.7.1.dist-info/RECORD +44 -0
  41. versionsec-0.7.1.dist-info/WHEEL +5 -0
  42. versionsec-0.7.1.dist-info/entry_points.txt +3 -0
  43. versionsec-0.7.1.dist-info/licenses/LICENSE +184 -0
  44. 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
@@ -0,0 +1,9 @@
1
+ """VersionSec by Gexiro.
2
+
3
+ Read-only security posture and supply-chain visibility for self-hosted Gitea.
4
+ Scope is limited to one own or explicitly authorized instance.
5
+ """
6
+
7
+ from .version import __version__
8
+
9
+ __all__ = ["__version__"]
versionsec/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -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
@@ -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