Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@

> Ingest SCAP/SCC/OpenSCAP/Wazuh findings → produce eMASS-ready POAM + OSCAL Assessment Results.

<!-- cognis:layman:start -->
## What is this?

stigsentry is a command-line tool for security and compliance teams who need to track and report on system vulnerabilities against government standards. It reads scan results from security tools like Wazuh or OpenSCAP and automatically maps each finding to the specific NIST 800-53 control and DISA STIG rule it violates. The output is a ready-to-import spreadsheet (POAM) for risk management systems like eMASS or Xacta, saving hours of manual cross-referencing. It is built for military, federal, and defense contractor teams who must maintain continuous ATO (Authority to Operate) packages.
<!-- cognis:layman:end -->

## Upstream

Forks / wraps **https://github.com/wazuh/wazuh**. See [`UPSTREAM.md`](./UPSTREAM.md) for the
Expand All @@ -17,6 +23,52 @@ licensing posture, supported commits, and how to upgrade.
- POAM CSV for eMASS / Xacta / RSA Archer
- OSCAL Assessment Results JSON

<!-- cognis:domains:start -->
## Domains

**Primary domain:** Government & Compliance · **JTF MERIDIAN division:** IRONCLAD · ANVIL

**Topics:** `cognis` `compliance` `govtech` `grc`

Part of the **Cognis Neural Suite** — 300+ source-available tools organized across 12 domains under the JTF MERIDIAN command structure. See the [suite on GitHub](https://github.com/cognis-digital) and [jtf-meridian](https://github.com/cognis-digital/jtf-meridian) for how the pieces fit together.
<!-- cognis:domains:end -->

<!-- cognis:install:start -->
## Install

`stigsentry` is source-available (not published to PyPI) — every method below installs
straight from GitHub. Pick whichever you prefer; the one-line scripts auto-detect
the best tool available on your machine.

**One-liner (Linux / macOS):**
```sh
curl -fsSL https://raw.githubusercontent.com/cognis-digital/stigsentry/HEAD/install.sh | sh
```

**One-liner (Windows PowerShell):**
```powershell
irm https://raw.githubusercontent.com/cognis-digital/stigsentry/HEAD/install.ps1 | iex
```

**Or install manually — any one of:**
```sh
pipx install "git+https://github.com/cognis-digital/stigsentry.git" # isolated (recommended)
uv tool install "git+https://github.com/cognis-digital/stigsentry.git" # uv
pip install "git+https://github.com/cognis-digital/stigsentry.git" # pip
```

**From source:**
```sh
git clone https://github.com/cognis-digital/stigsentry.git
cd stigsentry && pip install .
```

Then run:
```sh
stigsentry --help
```
<!-- cognis:install:end -->

## Install

```bash
Expand Down Expand Up @@ -68,7 +120,7 @@ These are emitted in JSON, SARIF, and the OSCAL skeleton.
```yaml
- name: stigsentry scan
run: |
pip install cognis-stigsentry
pip install "git+https://github.com/cognis-digital/stigsentry.git"
stigsentry . --format=oscal --out=assessment-results.json --fail-on=high
- name: Upload to eMASS/Xacta
run: cognis-rmf-package import assessment-results.json
Expand Down
16 changes: 11 additions & 5 deletions cognis_mil/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@
- CLI builder with --classification flag (placeholder only)
- Audit-log primitive (hash-chained, tamper-evident, local-only)
"""
from .models import Severity, Finding, ScanResult
from .exporters import to_console, to_json, to_sarif, to_markdown, to_oscal_skeleton
from .cli import make_cli
from .audit import AuditLog
from .classmark import ClassificationBanner # re-export for convenience
from .models import Severity as Severity, Finding as Finding, ScanResult as ScanResult
from .exporters import (
to_console as to_console,
to_json as to_json,
to_sarif as to_sarif,
to_markdown as to_markdown,
to_oscal_skeleton as to_oscal_skeleton,
)
from .cli import make_cli as make_cli
from .audit import AuditLog as AuditLog
from .classmark import ClassificationBanner as ClassificationBanner

__version__ = "0.1.0"
30 changes: 23 additions & 7 deletions cognis_mil/audit.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
"""Tamper-evident audit log. Hash-chained, append-only, local file."""
from __future__ import annotations
import hashlib, json, time

import hashlib
import json
import time
from pathlib import Path


class AuditLog:
def __init__(self, path: Path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)

def _last_hash(self) -> str:
if not self.path.exists(): return "GENESIS"
if not self.path.exists():
return "GENESIS"
try:
last = self.path.read_text().rstrip().split("\n")[-1]
return json.loads(last)["hash"]
Expand All @@ -30,17 +35,28 @@ def append(self, event: dict) -> dict:
return entry

def verify(self) -> tuple[bool, str]:
if not self.path.exists(): return True, "Empty log"
if not self.path.exists():
return True, "Empty log"
lines = self.path.read_text().splitlines()
if not lines:
return True, "Empty log"
prev = "GENESIS"
for i, line in enumerate(self.path.read_text().splitlines(), 1):
count = 0
for i, line in enumerate(lines, 1):
try:
e = json.loads(line)
except: return False, f"Line {i}: not valid JSON"
recomputed_body = json.dumps({k:e[k] for k in ("ts","prev","event")}, sort_keys=True, default=str)
except json.JSONDecodeError:
return False, f"Line {i}: not valid JSON"
recomputed_body = json.dumps(
{k: e[k] for k in ("ts", "prev", "event")},
sort_keys=True,
default=str,
)
recomputed = hashlib.sha256((recomputed_body + prev).encode()).hexdigest()
if recomputed != e["hash"]:
return False, f"Hash mismatch at line {i}"
if e["prev"] != prev:
return False, f"Prev mismatch at line {i}"
prev = e["hash"]
return True, f"Chain OK ({i} entries)"
count = i
return True, f"Chain OK ({count} entries)"
34 changes: 22 additions & 12 deletions cognis_mil/classmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,29 @@
Reference: ODNI CAPCO Implementation Manual (unclassified, public).
"""
from __future__ import annotations

from dataclasses import dataclass, field

VALID_LEVELS = ["UNCLASSIFIED", "CONFIDENTIAL", "SECRET", "TOP SECRET"]
VALID_FGI = ["FGI"] # Foreign Government Information marker placeholder
VALID_FGI = ["FGI"] # Foreign Government Information marker placeholder


@dataclass
class ClassificationBanner:
"""Builds a CAPCO-shape banner. Validation of *form*, not content."""
level: str = "UNCLASSIFIED" # operator-supplied
sci: list[str] = field(default_factory=list) # e.g. operator-supplied SCI compartments
sap: list[str] = field(default_factory=list) # SAP program IDs (operator-supplied)
dissem: list[str] = field(default_factory=list) # NOFORN/REL TO/ORCON etc. (operator-supplied)
nonic: list[str] = field(default_factory=list) # Non-IC dissem (FOUO/CUI etc.)

level: str = "UNCLASSIFIED" # operator-supplied
sci: list[str] = field(default_factory=list) # operator-supplied SCI compartments
sap: list[str] = field(default_factory=list) # SAP program IDs (operator-supplied)
dissem: list[str] = field(default_factory=list) # NOFORN/REL TO/ORCON etc.
nonic: list[str] = field(default_factory=list) # Non-IC dissem (FOUO/CUI etc.)

def validate(self) -> tuple[bool, list[str]]:
errs = []
if self.level not in VALID_LEVELS:
errs.append(f"Invalid base level: {self.level}. Expected one of {VALID_LEVELS}.")
errs.append(
f"Invalid base level: {self.level}. Expected one of {VALID_LEVELS}."
)
# Higher levels with no markings is a smell, but not invalid
if self.level == "UNCLASSIFIED" and (self.sci or self.sap):
errs.append("UNCLASSIFIED cannot carry SCI/SAP compartments")
Expand All @@ -32,13 +37,18 @@ def validate(self) -> tuple[bool, list[str]]:
def render(self) -> str:
"""Render the banner-line string. Operator content is passed through."""
parts = [self.level]
if self.sci: parts.append("/".join(self.sci))
if self.sap: parts.append("SAR-" + "/".join(self.sap))
if self.sci:
parts.append("/".join(self.sci))
if self.sap:
parts.append("SAR-" + "/".join(self.sap))
suffix = []
if self.dissem: suffix.extend(self.dissem)
if self.nonic: suffix.extend(self.nonic)
if self.dissem:
suffix.extend(self.dissem)
if self.nonic:
suffix.extend(self.nonic)
line = "//".join(parts)
if suffix: line += "//" + "/".join(suffix)
if suffix:
line += "//" + "/".join(suffix)
return line

@classmethod
Expand Down
103 changes: 84 additions & 19 deletions cognis_mil/cli.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,97 @@
import argparse, sys
import argparse
import sys

from .exporters import to_console, to_json, to_markdown, to_sarif, to_oscal_skeleton


def make_cli(tool_name, scan_fn, version="0.1.0", extra_args=None):
p = argparse.ArgumentParser(prog=tool_name, description=f"{tool_name} — Cognis Digital · Military/IC ecosystem")
p = argparse.ArgumentParser(
prog=tool_name,
description=f"{tool_name} — Cognis Digital · Military/IC ecosystem",
)
p.add_argument("target", nargs="?", default=".", help="Path/target")
p.add_argument("--format", choices=["console","json","markdown","sarif","oscal"], default="console")
p.add_argument(
"--format",
choices=["console", "json", "markdown", "sarif", "oscal"],
default="console",
)
p.add_argument("--out", help="Write output to file")
p.add_argument("--fail-on", choices=["very_high","high","moderate","low","none"], default="none")
p.add_argument("--classification", default="UNCLASSIFIED//FOR PUBLIC RELEASE",
help="Operator-supplied banner. PLACEHOLDER. Tool does not interpret.")
p.add_argument("-v","--version", action="version", version=f"{tool_name} {version}")
p.add_argument(
"--fail-on",
choices=["very_high", "high", "moderate", "low", "none"],
default="none",
)
p.add_argument(
"--classification",
default="UNCLASSIFIED//FOR PUBLIC RELEASE",
help="Operator-supplied banner. PLACEHOLDER. Tool does not interpret.",
)
p.add_argument(
"-v", "--version", action="version", version=f"{tool_name} {version}"
)
if extra_args:
for a in extra_args: p.add_argument(*a["flags"], **{k:v for k,v in a.items() if k!="flags"})
for a in extra_args:
p.add_argument(
*a["flags"],
**{k: v for k, v in a.items() if k != "flags"},
)
args = p.parse_args()
result = scan_fn(args.target, **{k:v for k,v in vars(args).items()
if k not in {"target","format","out","fail_on","version","classification"}})

# Run the scan, converting expected errors into clean stderr messages.
try:
result = scan_fn(
args.target,
**{
k: v
for k, v in vars(args).items()
if k not in {
"target", "format", "out", "fail_on", "version", "classification"
}
},
)
except Exception as exc: # noqa: BLE001
print(f"{tool_name}: error: {exc}", file=sys.stderr)
sys.exit(2)

result.classification_placeholder = args.classification
if hasattr(result, "finalize") and not result.composite_score: result.finalize()
fmt = {"console":to_console,"json":to_json,"markdown":to_markdown,"sarif":to_sarif,"oscal":to_oscal_skeleton}[args.format]
if hasattr(result, "finalize") and not result.composite_score:
result.finalize()

fmt = {
"console": to_console,
"json": to_json,
"markdown": to_markdown,
"sarif": to_sarif,
"oscal": to_oscal_skeleton,
}[args.format]
out = fmt(result)

if args.out:
open(args.out,"w").write(out); print(f"Wrote {args.out}", file=sys.stderr)
else: print(out)
try:
with open(args.out, "w") as fh:
fh.write(out)
print(f"Wrote {args.out}", file=sys.stderr)
except OSError as exc:
print(
f"{tool_name}: cannot write to {args.out!r}: {exc}",
file=sys.stderr,
)
sys.exit(2)
else:
print(out)

if args.fail_on != "none":
from .models import Severity
thresh = {"very_high":[Severity.VERY_HIGH],
"high":[Severity.VERY_HIGH,Severity.HIGH],
"moderate":[Severity.VERY_HIGH,Severity.HIGH,Severity.MODERATE],
"low":[Severity.VERY_HIGH,Severity.HIGH,Severity.MODERATE,Severity.LOW]}[args.fail_on]
if any(f.severity in thresh for f in result.findings): sys.exit(1)

thresh = {
"very_high": [Severity.VERY_HIGH],
"high": [Severity.VERY_HIGH, Severity.HIGH],
"moderate": [Severity.VERY_HIGH, Severity.HIGH, Severity.MODERATE],
"low": [
Severity.VERY_HIGH, Severity.HIGH, Severity.MODERATE, Severity.LOW
],
}[args.fail_on]
if any(f.severity in thresh for f in result.findings):
sys.exit(1)

sys.exit(0)
Loading
Loading