diff --git a/.ratchet.json b/.ratchet.json index ee9a5bbe..ab6869d0 100644 --- a/.ratchet.json +++ b/.ratchet.json @@ -1,7 +1,4 @@ { - "harnessing/3-audit/pqc-readiness/scripts/pqc_facts.py": { - "contracts": 1 - }, "src/traust/cli/auth.py": { "core-internal": 1 }, @@ -13,7 +10,6 @@ "database": 1 }, "src/traust/cli/check_docs_consistency.py": { - "contracts": 1, "state-file": 2 }, "src/traust/cli/check_skill_alignment.py": { diff --git a/harnessing/3-audit/pqc-readiness/SKILL.md b/harnessing/3-audit/pqc-readiness/SKILL.md index 3cffa6c0..7ff83142 100644 --- a/harnessing/3-audit/pqc-readiness/SKILL.md +++ b/harnessing/3-audit/pqc-readiness/SKILL.md @@ -86,8 +86,9 @@ If facts already exist (`--from-facts`), load them. Otherwise run the deterministic scanner: ```bash +syft dir: -o cyclonedx-json > -sbom.json python3 /scripts/pqc_facts.py \ - --repo-dir --repo-url \ + --repo-dir --repo-url --sha --sbom -sbom.json \ --out -pqc-facts.json --cbom-out -cbom.json traust pqc record --repo-url --sha --facts -pqc-facts.json traust pqc prepare --repo-url --sha @@ -562,8 +563,8 @@ anything uncertain `posture: unknown`. ## Two-layer probe model ``` -Layer 1 (census): python3 -m traust.cli adapters crypto-source → CRYPTO_* facts - ↓ imported by +Layer 1 (census): python3 -m traust.cli adapters crypto-source → CRYPTO_* facts (traust-core census) + ↓ read by pqc_facts.py → HP_CHAIN_* PQC facts + pqc_capable flag Layer 2 (TLS/code): pqc-scan rules (opengrep) → CWE/algorithm-level crypto usage @@ -576,7 +577,7 @@ Chain reasoning: crypto-analysis SKILL.md → governance chains (who owns | ----------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `crypto-source` | python3 -m traust.cli adapters crypto-source | Provider census: Go, Node, JDK, Python, Rust, .NET, Ruby, C/C++ crypto stacks, base images, RPM locks, GODEBUG, crypto-policies | | `crypto-cluster` | python3 -m traust.cli adapters crypto-cluster | Unopinionated data collector — `cluster` tier with `--groups` provides raw TLS negotiation facts for PQC probing | -| `pqc_facts.py` | `harnessing/3-audit/pqc-readiness/scripts/pqc_facts.py` | Imports crypto*probe, maps `CRYPTO*_`→`HP*CHAIN*_`, adds PQC capability assessment via `\_pqc_capable_version()`(reads thresholds from[`notes/reference/pqc-version-matrix.yaml`](notes/reference/pqc-version-matrix.yaml)) | +| `pqc_facts.py` | `harnessing/3-audit/pqc-readiness/scripts/pqc_facts.py` | Reads traust-core's crypto census of the commit (checkout + manifest SBOM), maps `CRYPTO*_`→`HP*CHAIN*_`, adds PQC capability assessment via `\_pqc_capable_version()`(reads thresholds from[`notes/reference/pqc-version-matrix.yaml`](notes/reference/pqc-version-matrix.yaml)) | | `scan_xcrypto_usage.py` | `harnessing/3-audit/pqc-readiness/scripts/scan_xcrypto_usage.py` | golang.org/x/crypto evidence: first-party import sites, go.mod versions, optional callgraph reachability (somepath witness) | | `traust pqc blockers` | traust-core `PqcService.blockers` | Derives each recorded report's `remediations[]` in the findings vocabulary (severity, classification, `patchable`) for `/patch` and other findings consumers | | `novel.py` | `harnessing/5-validate/validate-findings/novel.py` | Runtime probes: `pqc-tls-negotiation`, `pqc-cert-algorithm`, `pqc-crypto-policy`, `pqc-backend-tls` | diff --git a/harnessing/3-audit/pqc-readiness/scripts/pqc_facts.py b/harnessing/3-audit/pqc-readiness/scripts/pqc_facts.py index a089613a..29464abf 100755 --- a/harnessing/3-audit/pqc-readiness/scripts/pqc_facts.py +++ b/harnessing/3-audit/pqc-readiness/scripts/pqc_facts.py @@ -22,9 +22,12 @@ `PqcService.prescan` replaces it; record its output with `traust pqc record --facts`. Usage: - pqc_facts.py --repo-dir DIR --repo-url URL --out FACTS.json \ - [--scanner BIN] [--rules-dir DIR] [--cbom-out CBOM.json] - pqc_facts.py --from-sbom SBOM.json --repo-url URL --out FACTS.json + pqc_facts.py --repo-dir DIR --repo-url URL --sha SHA --out FACTS.json \ + [--sbom SBOM.json] [--scanner BIN] [--rules-dir DIR] [--cbom-out CBOM.json] + pqc_facts.py --from-sbom SBOM.json --repo-url URL --sha SHA --out FACTS.json + +The commit's manifest SBOM (`syft dir: -o cyclonedx-json`) is recorded first: +`--sbom` in source mode (unless already recorded), `--from-sbom` alone in SBOM mode. """ from __future__ import annotations @@ -41,17 +44,20 @@ from typing import Any import yaml # PyYAML — already in harness deps -from traust_contracts.paths import schema_path +from traust_core.v1.models.code_facts import CryptoFact, CryptoProbeId +from traust_core.v1.models.common import ToolRequest +from traust_core.v1.models.dependencies import NoManifestSbomError +from traust_core.v1.models.values import HttpsRepoUrl -from traust.cli.groups._registry import load_engine # wave 2 lane (e) -from traust.context import add_config_home_arg +from traust.cli._doors import register_commit +from traust.context import add_config_home_arg, load +from traust.paths import schema_path SCRIPT_DIR = Path(__file__).resolve().parent SKILL_DIR = SCRIPT_DIR.parent HERE = SKILL_DIR ADAPTER_VERSION = "1.4.0" # 1.4.0: HP_TLS_LISTENER_* census rules; HP_GOV_* facts first-party only PQC_SCAN_COMMIT = "be5c760adb7d1d4d99e39d35d87d2956079db76e" -from traust_engine.adapters.crypto_probe import * # noqa: F403, E402 VENDOR_RX = re.compile( r"(^|/)(vendor|node_modules|third_party|_vendor|3rdparty|third-party|" @@ -164,16 +170,17 @@ class Coverage: no_crypto_detected_assertion: str | None = None def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = {"assessment_basis": self.assessment_basis} + d: dict[str, Any] = { + "assessment_basis": self.assessment_basis, + "scanned_files": self.scanned_files, + "skipped_files": self.skipped_files, + "rules_in_pack": self.rules_in_pack, + } if self.assessment_basis == "sbom-only": d["sbom"] = self.sbom d["components_seen"] = self.components_seen - else: - d["scanned_files"] = self.scanned_files - d["skipped_files"] = self.skipped_files - d["rules_in_pack"] = self.rules_in_pack - if self.no_crypto_detected_assertion: - d["no_crypto_detected_assertion"] = self.no_crypto_detected_assertion + elif self.no_crypto_detected_assertion: + d["no_crypto_detected_assertion"] = self.no_crypto_detected_assertion return d @@ -424,10 +431,35 @@ def _parse_scanner_findings(report: dict) -> list[Fact]: return facts -def _adapter_native_facts(repo: Path, tables: dict) -> list[Fact]: - """Provider / policy / kill-switch / material facts via crypto_probe.""" +def crypto_census(args: argparse.Namespace) -> list[CryptoFact]: + """The commit's crypto census from traust-core, recording the repository, the commit and + its manifest SBOM: the checkout's configured stacks and the SBOM's crypto packages, or the + packages alone (`--from-sbom`).""" + core = load(args.config_home) + services = core.services() + url = HttpsRepoUrl.parse(args.repo_url, core.config.forge_hosts) + repo = services.portfolio.register_repository(url, args.public) + ref = register_commit(services, repo.id, args.sha, None) + assert ref.id is not None + sbom = args.from_sbom or args.sbom + if sbom: + services.exposure.ingest_sbom(Path(sbom).read_bytes(), ref.id) + try: + if args.from_sbom: + return services.code_facts.packages(ref.id) + census = services.code_facts.crypto(ref.id, ToolRequest(workspace=Path(args.repo_dir))) + except NoManifestSbomError as e: + raise SystemExit( + f"pqc_facts: commit {args.sha} has no manifest SBOM recorded: run " + f"`syft dir:{args.repo_dir} -o cyclonedx-json` and pass it as --sbom" + ) from e + # A source scan reads the packages its ecosystems name; keyword components are SBOM-mode only. + return [f for f in census if f.probe_id != CryptoProbeId.SBOM_COMPONENT] + + +def _adapter_native_facts(repo: Path, general_facts: list[CryptoFact], tables: dict) -> list[Fact]: + """Provider / policy / kill-switch / material facts from the crypto census.""" cap = tables["provider_capability"] - general_facts = crypto_probe.probe_repo(repo) # noqa: F405 facts: list[Fact] = [] fips_mode = any( @@ -442,10 +474,11 @@ def _adapter_native_facts(repo: Path, tables: dict) -> list[Fact]: if not rule_id: continue + at = gf.location fact = Fact( rule_id=rule_id, - file=gf.file, - line=gf.line, + file="" if at is None else str(at.path), + line=None if at is None else at.line, detail=gf.detail[:200], provider=gf.provider or None, pqc_capable=_pqc_capable_version(gf.provider, gf.version), @@ -499,14 +532,12 @@ def _adapter_native_facts(repo: Path, tables: dict) -> list[Fact]: return facts -def _sbom_facts(sbom_path: Path, tables: dict) -> list[Fact]: - """--from-sbom mode: provider + delegated facts from a CycloneDX SBOM.""" - general_facts = crypto_probe.probe_sbom(sbom_path) # noqa: F405 +def _sbom_facts(general_facts: list[CryptoFact], tables: dict) -> list[Fact]: + """--from-sbom mode: provider + delegated facts from the SBOM's crypto packages.""" return [ Fact( rule_id="HP_SBOM_CRYPTO_COMPONENT", - file=gf.file, - line=gf.line, + file="", detail=gf.detail[:200], pqc_capable=_pqc_capable_version(gf.provider, gf.version), ) @@ -560,7 +591,7 @@ def _build_summary(facts: list[Fact]) -> dict[str, dict[str, int]]: # --------------------------------------------------------------------------- -def build_facts(args) -> FactsDocument: +def build_facts(args, census: list[CryptoFact]) -> FactsDocument: tables = load_tables() rules_dir = Path(args.rules_dir or (HERE / "rules")) stamps = { @@ -570,7 +601,7 @@ def build_facts(args) -> FactsDocument: } if args.from_sbom: - facts = _sbom_facts(Path(args.from_sbom), tables) + facts = _sbom_facts(census, tables) coverage = Coverage( assessment_basis="sbom-only", sbom=str(args.from_sbom), @@ -590,7 +621,7 @@ def build_facts(args) -> FactsDocument: raw_summary = report.get("summary") or {} facts = _parse_scanner_findings(report) - facts.extend(_adapter_native_facts(repo, tables)) + facts.extend(_adapter_native_facts(repo, census, tables)) n_rules = sum( len(re.findall(r"(?m)^- id:", p.read_text())) for p in sorted(rules_dir.glob("*.yml")) @@ -659,6 +690,9 @@ def main(): add_config_home_arg(ap) ap.add_argument("--repo-dir") ap.add_argument("--repo-url", default=None) + ap.add_argument("--sha", help="the checked-out commit") + ap.add_argument("--public", action="store_true", help="the repository is public") + ap.add_argument("--sbom", help="the commit's manifest SBOM (syft dir:)") ap.add_argument("--out") ap.add_argument("--scanner") ap.add_argument("--rules-dir") @@ -671,11 +705,10 @@ def main(): if args.validate_facts: p = Path(args.validate_facts) raise SystemExit(min(validate_facts(json.loads(p.read_text()), str(p)), 1)) - if not args.out or not (args.repo_dir or args.from_sbom): - ap.error("need --out and one of --repo-dir/--from-sbom") - load_engine(args.config_home) + if not (args.out and args.repo_url and args.sha) or not (args.repo_dir or args.from_sbom): + ap.error("need --out, --repo-url, --sha and one of --repo-dir/--from-sbom") - doc = build_facts(args) + doc = build_facts(args, crypto_census(args)) serialized = doc.to_dict() if validate_facts(serialized, args.out): raise SystemExit( diff --git a/harnessing/5-validate/validate-findings/adapters/base.py b/harnessing/5-validate/validate-findings/adapters/base.py index 23abc5ad..214cc386 100644 --- a/harnessing/5-validate/validate-findings/adapters/base.py +++ b/harnessing/5-validate/validate-findings/adapters/base.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import ClassVar +from traust.context import load + @dataclass class Fingerprint: @@ -156,7 +158,7 @@ def _classify_content(cls, payload: str | None, cmd: str | None) -> str: # Step text originates in finding prose — hostile per the threat # model. Validation and execution of string-form (PoC-derived) - # steps delegate to traust_engine._util.safe_exec under the + # steps delegate to traust-core's safe-exec under the # "validation-step" profile ($TRAUST_CONFIG_HOME/safe-exec-profiles.yaml): # substitution/operator denies, per-segment binary allowlist, and # native pipeline execution with a scrubbed environment — no shell @@ -184,15 +186,13 @@ def _classify_content(cls, payload: str | None, cmd: str | None) -> str: } ) - _safe_exec_mod = None + _safe_exec_policy = None @classmethod def _safe_exec(cls): - if cls._safe_exec_mod is None: - import importlib - - cls._safe_exec_mod = importlib.import_module("traust_engine._util.safe_exec") - return cls._safe_exec_mod + if cls._safe_exec_policy is None: + cls._safe_exec_policy = load().safe_exec + return cls._safe_exec_policy @classmethod def _vet_shell_string(cls, cmd: str) -> tuple[list[str] | None, str]: @@ -200,7 +200,7 @@ def _vet_shell_string(cls, cmd: str) -> tuple[list[str] | None, str]: (argv, "") for a pipeless command, (None, "") for an approved pipeline, or (None, reason) when rejected.""" se = cls._safe_exec() - v = se.vet_command_string(cmd, se.get_profile(cls._SAFE_EXEC_PROFILE)) + v = se.vet_command_string(cmd, se.profile(cls._SAFE_EXEC_PROFILE)) if not v.ok: return None, v.reason if len(v.segments) == 1: @@ -213,7 +213,7 @@ def _run( ) -> tuple[int, str, str]: if isinstance(cmd, str): se = cls._safe_exec() - profile = se.get_profile(cls._SAFE_EXEC_PROFILE) + profile = se.profile(cls._SAFE_EXEC_PROFILE) v = se.vet_command_string(cmd, profile) if not v.ok: return 126, "", f"[step blocked: {v.reason}]" diff --git a/harnessing/validation-fuzz-dashboard/SKILL.md b/harnessing/validation-fuzz-dashboard/SKILL.md index 62a28c55..75e271d7 100644 --- a/harnessing/validation-fuzz-dashboard/SKILL.md +++ b/harnessing/validation-fuzz-dashboard/SKILL.md @@ -107,15 +107,15 @@ If `summary` was requested, also print the verdict-by-severity table. | `Live-validation-fuzz-dashboard.html` | Self-contained dashboard: hero (confirmed-exploitable), KPI tiles, verdict-by-severity stacked bars, coverage meter, top-targets bars, fuzz pattern hit-rates, confirmed fuzz-bug and confirmed-Critical tables. | | `Live-validation-fuzz-dashboard.md` | Markdown one-pager with the same numbers — headline, verdict×severity table, top targets, fuzz hit-rates/bugs, confirmed-Critical list. Paste-ready for docs or Slack. | -Both outputs embed the standard population block (python3 -m traust.cli corpus) for -cross-dashboard reconciliation. +*Retired:* the standard population block. It read the corpus config, which the +engine no longer loads, so it was always omitted. The script is idempotent and read-only over its inputs — re-run it whenever new validation reports land or the fuzz summary changes. ## Trends -Each run appends `confirmed_exploitable` / `refuted` / `checks_attempted` / +*Retired:* the run no longer appends `confirmed_exploitable` / `refuted` / `checks_attempted` / `chains_confirmed` / `fuzz_bugs` (source `validation-fuzz`) to the central -metrics ledger (python3 -m traust.cli metrics history, no-op when unchanged) and injects -a trend-vs-previous banner into both outputs. +metrics ledger or injects a trend-vs-previous banner (the metrics history is retired; see +[`traust-metrics`](../traust-metrics/SKILL.md)). diff --git a/harnessing/validation-fuzz-dashboard/scripts/build_validation_fuzz_dashboard.py b/harnessing/validation-fuzz-dashboard/scripts/build_validation_fuzz_dashboard.py index 73b7463c..6293365c 100644 --- a/harnessing/validation-fuzz-dashboard/scripts/build_validation_fuzz_dashboard.py +++ b/harnessing/validation-fuzz-dashboard/scripts/build_validation_fuzz_dashboard.py @@ -26,7 +26,6 @@ load, progress_tracker_dir, resolve_results_root, - workspace_dir, ) VERDICTS = ["CONFIRMED", "INCONCLUSIVE", "BLOCKED_BY_SCOPE", "REFUTED"] # attempted, bad->good @@ -405,103 +404,6 @@ def build_markdown(v, fz, now, totals, attempted, grand, cov, stacked_tbl): return "\n".join(lines) -# --- shared metrics ledger (best-effort; dashboards never fail on it) ------- - - -def _ledger_soft(config_home): - try: - import importlib - - mod = importlib.import_module("traust_engine.metrics.history") - ws = workspace_dir(load(config_home)) - return mod, ws - except SystemExit: - return None, None - except Exception: - pass - return None, None - - -def _inject_trend(path, text): - """Insert the trend line into an already-rendered output (md or html).""" - try: - import re as _re - from pathlib import Path as _P - - p = _P(path) - s = p.read_text(encoding="utf-8") - if p.suffix == ".md": - head, _, rest = s.partition("\n") - s = head + f"\n\n\U0001f4c8 *{text}*\n" + rest - else: - s = _re.sub( - r"(]*>)", - lambda m: ( - m.group(1) + "
" - "\U0001f4c8 " + text + "
" - ), - s, - count=1, - ) - p.write_text(s, encoding="utf-8") - except OSError: - pass - - -# --- standard population block (traust.cli.groups.corpus; best-effort) ------------ - - -def _population_lines(counts): - """Markdown lines of the standard population block, [] if corpus.py is - unavailable — the dashboard must render either way.""" - try: - import importlib - - corpus = importlib.import_module("traust_engine.corpus.resolver") - cfg = corpus.load_config() - roots = corpus.roots_description( - cfg, - None, - extra=[ - "`validations/*/` (live-validation verdicts)", - "`FUZZ-CAMPAIGN-SUMMARY.md` (offline fuzz results)", - ], - ) - return corpus.population_block_lines( - tool="validation-fuzz-dashboard", - roots=roots, - unit="per-finding live-validation verdicts (CONFIRMED/REFUTED/" - "INCONCLUSIVE/BLOCKED_BY_SCOPE/NOT_ATTEMPTED) + attack " - "chains", - filters="NOT_ATTEMPTED excluded from coverage %; REFUTED = removed false positives", - denominator="directory walk of validations/*/ for *validation*.json", - counts=counts, - ) - except Exception as e: - print(f"[!] population block omitted: {e}", file=sys.stderr) - return [] - - -def _population_html(lines): - """Render the population block lines as a final HTML panel.""" - if not lines: - return "" - lis = [] - for ln in lines: - if not ln.startswith("- "): - continue # heading/blank handled by the

below - t = esc(ln[2:]) - t = re.sub(r"\*\*(.+?)\*\*", r"\1", t) - t = re.sub(r"`([^`]+)`", r"\1", t) - lis.append(f"
  • {t}
  • ") - return ( - '\n

    Population

    \n
      ' + "".join(lis) + "
    \n" - ) - - def markdown_path(out_path, out_md=None): """Resolve the Markdown companion for the HTML output. @@ -514,7 +416,7 @@ def markdown_path(out_path, out_md=None): return Path(out_md) if out_md else Path(out_path).with_suffix(".md") -def build(root, out_path, out_md=None, config_home=None): +def build(root, out_path, out_md=None): v = scan_validations(root) fz = scan_fuzz(root) now = datetime.date.today().isoformat() @@ -564,16 +466,6 @@ def build(root, out_path, out_md=None, config_home=None): cls="num", ) - pop_lines = _population_lines( - { - "Validation reports parsed": f"{v['reports']:,}", - "Finding-checks attempted": f"{attempted:,} / {grand:,} ({cov:.1f}% coverage)", - "Confirmed": f"{confirmed:,}", - "Refuted": f"{totals['REFUTED']:,}", - } - ) - pop_html = _population_html(pop_lines) - page = f""" Live Validation & Fuzzing — Portfolio Dashboard @@ -681,7 +573,6 @@ def build(root, out_path, out_md=None, config_home=None): {table(["Target", "Finding", "Observed impact"], [(t, ti, (im[:110] + "…") if len(im) > 110 else im) for t, ti, im in crit_rows])}
    First 25 of {len(v["confirmed_crit"])} confirmed-Critical validations, alphabetical by target.
    -{pop_html} @@ -711,8 +602,6 @@ def build(root, out_path, out_md=None, config_home=None): with Path(out_path).open("w", encoding="utf-8") as f: f.write(page) md = build_markdown(v, fz, now, totals, attempted, grand, cov, stacked_tbl) - if pop_lines: - md += "\n---\n\n" + "\n".join(pop_lines) + "\n" out_md = markdown_path(out_path, out_md) with Path(out_md).open("w", encoding="utf-8") as f: f.write(md) @@ -722,29 +611,6 @@ def build(root, out_path, out_md=None, config_home=None): + f", NOT_ATTEMPTED={v['not_attempted']:,}" ) print(f"[+] fuzz: {len(fz['bugs'])} confirmed bugs, {len(fz['patterns'])} patterns") - ml, _ws = _ledger_soft(config_home) - if ml: - headline = { - "confirmed_exploitable": confirmed, - "refuted": totals.get("REFUTED"), - "checks_attempted": attempted, - "chains_confirmed": v.get("chains_live"), - "fuzz_bugs": int(b) - if str(b := (fz.get("totals") or {}).get("bugs", "")).isdigit() - else None, - } - prev = ml.previous("validation-fuzz") - tline = ml.trend_line( - headline, - prev, - ["confirmed_exploitable", "refuted", "checks_attempted", "chains_confirmed"], - ) - if tline: - _inject_trend(out_path, tline) - _inject_trend(out_md, tline) - if ml.append_if_changed("validation-fuzz", headline): - print("[+] metrics snapshot appended (validation-fuzz)") - print(f"[+] wrote {out_path}") print(f"[+] wrote {out_md}") @@ -783,7 +649,7 @@ def main(argv=None) -> int: results = resolve_results_root(args) dash = _dashboards_home(args.config_home) out = args.out or str((dash or results) / "Live-validation-fuzz-dashboard.html") - build(results, out, args.out_md, config_home=args.config_home) + build(results, out, args.out_md) return 0 diff --git a/src/traust/cli/check_docs_consistency.py b/src/traust/cli/check_docs_consistency.py index 38a63359..5dd91cb8 100644 --- a/src/traust/cli/check_docs_consistency.py +++ b/src/traust/cli/check_docs_consistency.py @@ -44,9 +44,7 @@ import sys from pathlib import Path -from traust_contracts.paths import schema_dir - -from traust.paths import HARNESS_ROOT, skill_dirs, skill_scripts +from traust.paths import HARNESS_ROOT, schema_dir, skill_dirs, skill_scripts from traust.paths import optional_config_path as _cfg_path REPO = HARNESS_ROOT diff --git a/tests/test_engine_imports.py b/tests/test_engine_imports.py index 5a5a84d2..32f99e50 100644 --- a/tests/test_engine_imports.py +++ b/tests/test_engine_imports.py @@ -13,7 +13,6 @@ ROOT = Path(__file__).resolve().parents[1] TOOLS_MODULE = "src/traust/_tools.py" BASELINE = { - "harnessing/3-audit/pqc-readiness/scripts/pqc_facts.py": 1, "src/traust/cli/groups/_registry.py": 1, "src/traust/cli/groups/metrics.py": 1, "src/traust/cli/groups/portfolio.py": 1, diff --git a/tests/test_p2_hardening.py b/tests/test_p2_hardening.py index af4ae823..fce46e59 100644 --- a/tests/test_p2_hardening.py +++ b/tests/test_p2_hardening.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from traust_core.v1.errors import ConfigError _ROOT = Path(__file__).resolve().parents[1] @@ -12,8 +13,8 @@ # ---- C2: step-text execution gate ---------------------------------- -# Reason fragments follow traust_engine._util.safe_exec wording since v0.229.0 -# (base.py delegates string-step vetting there). +# Reason fragments follow traust-core's safe-exec wording (base.py delegates +# string-step vetting there). @pytest.mark.parametrize( "cmd,frag", [ @@ -46,6 +47,13 @@ def test_run_blocks_instead_of_crashing(): assert rc == 126 and "step blocked" in err +def test_string_steps_need_the_config_homes_safe_exec_profiles(tmp_path, monkeypatch): + monkeypatch.setenv("TRAUST_CONFIG_HOME", str(tmp_path)) + monkeypatch.setattr(AdapterBase, "_safe_exec_policy", None) + with pytest.raises(ConfigError, match="safe-exec-profiles"): + AdapterBase._vet_shell_string("oc get pods") + + def test_classify_ifs_evasion_not_safe(): assert ( AdapterBase().classify("raw", cmd="rm${IFS}-rf /") != "safe" or True diff --git a/tests/test_pqc_facts.py b/tests/test_pqc_facts.py new file mode 100644 index 00000000..d55cdafd --- /dev/null +++ b/tests/test_pqc_facts.py @@ -0,0 +1,107 @@ +"""pqc-readiness's `pqc_facts.py` on traust-core's crypto census, SQLite and Postgres; the pinned +pqc-scan binary is a fake, so only the census and its mapping run.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from tests.test_products_doors import _postgres +from traust.paths import skill_dir + +FIXTURES = Path(__file__).parent / "fixtures" / "crypto" +SBOM = FIXTURES / "manifest-sbom.cdx.json" +REPO = "https://github.com/org/app" +SHA = "a" * 40 + +_SPEC = importlib.util.spec_from_file_location( + "pqc_facts", skill_dir("pqc-readiness") / "scripts" / "pqc_facts.py" +) +pqc_facts = importlib.util.module_from_spec(_SPEC) +sys.modules["pqc_facts"] = pqc_facts +_SPEC.loader.exec_module(pqc_facts) + + +@pytest.fixture(params=["sqlite", "postgres"]) +def home(request: pytest.FixtureRequest, tmp_path: Path) -> Iterator[Path]: + config = tmp_path / "config" + config.mkdir() + core: dict[str, Any] = {"home": str(tmp_path / "store")} + with contextlib.ExitStack() as stack: + if request.param == "postgres": + core["database"] = {"url": stack.enter_context(_postgres())} + (config / "core.yaml").write_text(yaml.safe_dump(core)) + yield config + + +@pytest.fixture +def scanner(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + binary = tmp_path / "pqc-scan" + binary.write_bytes(b"fake") + report = {"summary": {"scanned_files": 19, "skipped_files": 0}, "findings": []} + monkeypatch.setattr(pqc_facts, "run_scanner", lambda *_: report) + return binary + + +def facts(monkeypatch: pytest.MonkeyPatch, home: Path, out: Path, *argv: str) -> dict: + args = ["--config-home", str(home), "--repo-url", REPO, "--sha", SHA, "--out", str(out)] + monkeypatch.setattr(sys, "argv", ["pqc_facts.py", *args, *argv]) + with contextlib.redirect_stdout(io.StringIO()): + pqc_facts.main() + return json.loads(out.read_text()) + + +def test_source_mode_maps_the_checkout_and_its_sbom_packages( + home: Path, scanner: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + doc = facts( + monkeypatch, + home, + tmp_path / "facts.json", + *("--repo-dir", str(FIXTURES / "repo"), "--sbom", str(SBOM), "--scanner", str(scanner)), + ) + by_rule = {f["rule_id"]: f for f in doc["facts"]} + assert doc["coverage"]["assessment_basis"] == "source" + assert by_rule["HP_CHAIN_GO_TOOLCHAIN"]["file"] == "go.mod" + assert by_rule["HP_CHAIN_GO_TOOLCHAIN"]["pqc_capable"] is True + assert {"HP_CHAIN_PYTHON_CRYPTO_DEP", "HP_CHAIN_RUST_TLS_BACKEND"} <= by_rule.keys() + assert "HP_SBOM_CRYPTO_COMPONENT" not in by_rule + assert all(f["fips_mode"] for f in doc["facts"] if f["rule_id"].startswith("HP_CHAIN_")) + + +def test_source_mode_without_a_recorded_sbom_says_how_to_record_one( + home: Path, scanner: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + out = tmp_path / "facts.json" + with pytest.raises(SystemExit, match=f"commit {SHA} has no manifest SBOM recorded"): + facts(monkeypatch, home, out, "--repo-dir", str(FIXTURES / "repo")) + assert not out.exists() + + +def test_sbom_mode_is_every_crypto_package_as_an_sbom_component( + home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + doc = facts(monkeypatch, home, tmp_path / "facts.json", "--from-sbom", str(SBOM)) + assert doc["coverage"]["assessment_basis"] == "sbom-only" + assert doc["coverage"]["scanned_files"] is None + assert {f["rule_id"] for f in doc["facts"]} == {"HP_SBOM_CRYPTO_COMPONENT"} + details = {f["detail"] for f in doc["facts"]} + assert {"cryptography==43.0.1", "golang.org/x/crypto v0.36.0"} <= details + assert not any("flask" in d or "rails" in d for d in details) + + +def test_a_commit_is_required(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + argv = ["pqc_facts.py", "--from-sbom", str(SBOM), "--repo-url", REPO, "--out", "x.json"] + monkeypatch.setattr(sys, "argv", argv) + with pytest.raises(SystemExit) as exit_, contextlib.redirect_stderr(io.StringIO()): + pqc_facts.main() + assert exit_.value.code == 2