diff --git a/docs/usage.md b/docs/usage.md index ec318ad..4667f62 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -56,6 +56,8 @@ coretrace-python-analyzer [--check | --emit-ir [--ssa]] [options] [path] | `--import-advisories SRC OUT` | Convert an OSV dump into the local advisory file `OUT`, then exit. | | `--fail-on SEVERITY` | Exit with status 1 only when a finding of this severity or above was reported: `info`, `low`, `medium`, `high` or `critical`. Default: any finding. | | `--baseline PATH` | The accepted findings: written on the first run, then only findings not recorded there fail the check. | +| `--baseline-key-file FILE` | With `--baseline`, the file holding the key of a keyed baseline (schema 4), kept out of the analysed directory; read before `CORETRACE_BASELINE_KEY`. | +| `--record-baseline` | With `--baseline`, record the current findings as the baseline, replacing the file only once the check succeeded. | | `--emit-ir` | Print the intermediate representation of `path` instead of checking it. | | `--ssa` | With `--emit-ir`, print the static single assignment form. | | `--help` | Show the options and exit. | @@ -240,18 +242,50 @@ new finding. Entries hold a digest of that text, value and pointer, never the te itself, so no accepted secret is stored in plain text. That does not make the file confidential: a digest does not hide a weak secret from whoever guesses it, since a guessed password, hashed with its line, can be checked against the file. Treat the -baseline as a possibly sensitive file. - -Baselines are written in schema 3. A file written by an earlier version, in schema 1 or -2, is still read and never rewritten, and the check warns on standard error, once per -run, that it may contain secrets in plain text: its entries hold the text of each +baseline as a possibly sensitive file, or key it. + +A keyed baseline does not let candidate values be tested without its key. The key comes +from the file `--baseline-key-file` names, or else from the `CORETRACE_BASELINE_KEY` +environment variable, as a CI secret; the file comes first when both are given. The check +refuses a key file inside the analysed directory, however its path is spelled, or the +baseline itself; keep it out of the whole repository too, which the check cannot tell +(a hard link escapes it as well). Generate the key at random, for instance with +`python -c "import secrets; print(secrets.token_hex(32))"`, and share it as a secret +(a CI secret, a password manager): a key a person chooses can be guessed from the +baseline. It is at least 16 bytes long, and trailing line breaks are not part of it, from +the file or the variable alike; an empty variable is an error, as a CI job whose secret +is missing passes one. Every process of the check, plugins and `--jobs` workers included, +can read the variable. With a key, the +baseline is recorded in schema 4: every digest is an HMAC-SHA-256 under the key, and a +check value, never the key, tells which key recorded it. The key appears in no report, +message or file the check writes. A schema 4 baseline needs its key: without it, or +with another, the check stops with an explicit error, exit status 2, and records +nothing. Recording over a schema 4 baseline without its key is refused too, rather than +turning it back into an unkeyed one: delete the file to do that explicitly. Without a +key, baselines are recorded in schema 3, with the guarantee above. + +`--record-baseline` records the current findings as the baseline even when the file +exists, under the key given, if any; the file (the target of a symbolic link) is +replaced at once, keeping its permissions, only once the analysis succeeded and its +report is rendered, so the previous baseline stays in place when the check fails. Review the findings +before recording: they all become accepted. Rotating the key is such a record under the +new key, once the findings are reviewed; the old key then no longer reads the baseline. + +Baselines are written in schema 3, or in schema 4 with a key. A file written by an +earlier version, in schema 1 or 2, is still read and never rewritten, and the check warns +on standard error, once per run, that it may contain secrets in plain text: its entries +hold the text of each finding's first line. They match exactly as they did a finding on a single line; a finding over several lines, whose other lines they do not hold, is reported as new, and so is a finding in a JSON, TOML or lock file that a schema 1 entry, recorded with the text of its key's first line, cannot identify with certainty. The exit status depends on the findings only. Review such a baseline and its new findings, then migrate it -explicitly to schema 3: delete it and run the check again. The old file's secrets -remain in the history of a repository that committed it. +explicitly with `--record-baseline`, to schema 3, or to schema 4 with a key. While a key +is given, only a schema 4 baseline is read: an unkeyed one (schema 1, 2 or 3) stops the +check with an error, so that it cannot stand in for the keyed one; review it, then record +it in schema 4 with `--record-baseline`. No baseline is ever converted or rewritten +without `--record-baseline`. The +old file's secrets remain in the history of a repository that committed it. ## Reports diff --git a/src/coretrace_python/cli.py b/src/coretrace_python/cli.py index 1408c72..bb8633b 100644 --- a/src/coretrace_python/cli.py +++ b/src/coretrace_python/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import os import sys import zipfile from datetime import UTC, datetime @@ -18,7 +19,13 @@ render_vex, ) from coretrace_python.findings import Severity -from coretrace_python.findings.baseline import Baseline, BaselineError +from coretrace_python.findings.baseline import ( + KEY_ENVIRONMENT, + Baseline, + BaselineError, + baseline_key, + ensure_recordable, +) from coretrace_python.frontend import HIRBuildError, ParseError, build_hir from coretrace_python.ir.lowering import LoweringError, lower_module from coretrace_python.ir.printer import format_module @@ -148,6 +155,20 @@ def build_parser() -> argparse.ArgumentParser: help="with --check, the accepted findings: written on the first run, then only " "findings not recorded there fail the check", ) + parser.add_argument( + "--baseline-key-file", + type=Path, + default=None, + metavar="FILE", + help=f"with --baseline, the file holding the key of a keyed (schema 4) baseline, kept out " + f"of the analysed directory; read before {KEY_ENVIRONMENT}", + ) + parser.add_argument( + "--record-baseline", + action="store_true", + help="with --baseline, record the current findings as the baseline, replacing the " + "file only once the check succeeded", + ) parser.add_argument( "--fail-on", choices=[severity.value for severity in Severity], @@ -215,6 +236,9 @@ def main(argv: list[str] | None = None) -> int: if args.baseline is not None and not args.check: print("error: --baseline only applies to --check", file=sys.stderr) return EXIT_ERROR + if (args.baseline_key_file is not None or args.record_baseline) and args.baseline is None: + print("error: --baseline-key-file and --record-baseline only apply with --baseline", file=sys.stderr) + return EXIT_ERROR if args.no_bundled_plugins and not args.check: print("error: --no-bundled-plugins only applies to --check", file=sys.stderr) return EXIT_ERROR @@ -225,6 +249,17 @@ def main(argv: list[str] | None = None) -> int: return EXIT_ERROR try: + root = args.path.resolve() if args.path.is_dir() else args.path.resolve().parent + # The key and the baseline are read before the analysis: a baseline that cannot be + # read stops the check before anything is recorded. + key = None + previous = None + if args.baseline is not None: + key = baseline_key(args.baseline_key_file, os.environ, root, args.baseline) + if args.record_baseline or not args.baseline.is_file(): + ensure_recordable(args.baseline, key) + else: + previous = Baseline.load(args.baseline, key) if args.emit_ir: source = SourceManager().load_file(args.path) print(format_module(lower_module(build_hir(source), ssa=args.ssa))) @@ -270,20 +305,23 @@ def main(argv: list[str] | None = None) -> int: findings, coverage = file_analysis.findings, file_analysis.coverage suppressed = file_analysis.suppressed components = () - root = args.path.resolve() if args.path.is_dir() else args.path.resolve().parent baselined = None - if args.baseline is not None: - if args.baseline.is_file(): - baseline = Baseline.load(args.baseline) - findings, baselined = baseline.partition(findings, root) - notice = baseline.transition_notice() - if notice is not None: - print(f"coretrace: {args.baseline}: {notice}", file=sys.stderr) - else: - Baseline.of(findings, root).save(args.baseline) - findings, baselined = (), findings + recorded = None + if previous is not None: + findings, baselined = previous.partition(findings, root) + notice = previous.transition_notice() + if notice is not None: + print(f"coretrace: {args.baseline}: {notice}", file=sys.stderr) + elif args.baseline is not None: + recorded = Baseline.of(findings, root, key) + findings, baselined = (), findings report = engine.report(findings, coverage, root, suppressed, baselined, components) - print(render(args.format or "text", report), end="") + text = render(args.format or "text", report) + # Recorded once the report is rendered, so that a failed check keeps the + # previous baseline. + if recorded is not None: + recorded.save(args.baseline) + print(text, end="") threshold = Severity(args.fail_on).rank if args.fail_on is not None else 0 failing = any(finding.severity.rank >= threshold for finding in findings) return EXIT_FINDINGS if failing else EXIT_CLEAN diff --git a/src/coretrace_python/findings/baseline.py b/src/coretrace_python/findings/baseline.py index 1fa0ae9..162cd47 100644 --- a/src/coretrace_python/findings/baseline.py +++ b/src/coretrace_python/findings/baseline.py @@ -18,14 +18,30 @@ the key was not repeated: such an entry still identifies that finding, and no other. Reading an old baseline, the check warns, with ``transition_notice``, that it may hold secrets in plain text and is to be reviewed and recorded again. + +A schema 3 digest lets anyone test candidate values offline. With a key (#225), from a +key file given explicitly, kept out of the analysed directory, or else from +``CORETRACE_BASELINE_KEY``, the baseline is recorded in schema 4: every digest is an +HMAC-SHA-256 under the key, and a check value tells the key apart without revealing it. +A schema 4 baseline needs its key: without it, or with another, it cannot be read; and +with a key, only a schema 4 baseline is read, so that an unkeyed one cannot stand in for +it. No baseline is ever rewritten on its own: moving to schema 4, or to another key, is +an explicit record, written only once the check succeeded, the previous file kept until +then. The key never leaves this module. """ from __future__ import annotations +import hashlib +import hmac import json +import os +import stat +import tempfile from collections import Counter -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field +from functools import partial from pathlib import Path from coretrace_python.findings.model import Finding @@ -33,6 +49,9 @@ from coretrace_python.source.positions import text_digest BASELINE_SCHEMA = 3 +KEYED_SCHEMA = 4 +KEY_ENVIRONMENT = "CORETRACE_BASELINE_KEY" +_MINIMUM_KEY = 16 # Schemas whose entries hold the text of a finding's first line. _LEGACY_SCHEMAS = (1, 2) @@ -43,16 +62,110 @@ class BaselineError(Exception): - """The baseline file cannot be read.""" + """The baseline file cannot be read, or its key cannot be used.""" + + +class BaselineKey: + """The secret key of a schema 4 baseline, which nothing shows.""" + + __slots__ = ("_secret",) + + def __init__(self, secret: bytes) -> None: + self._secret = secret + + def __repr__(self) -> str: + return "BaselineKey()" + + def digest(self, text: str) -> str: + mac = hmac.new(self._secret, text.encode("utf-8", "surrogatepass"), hashlib.sha256) + return "hmac-sha256:" + mac.hexdigest()[:32] + + @property + def check(self) -> str: + """A value that tells this key apart from another, without revealing it.""" + return self.digest("coretrace baseline key check") + + +def baseline_key( + key_file: Path | None, environ: Mapping[str, str], root: Path, baseline: Path | None = None +) -> BaselineKey | None: + """The key of the baseline: read from ``key_file`` when one is given, which must lie + outside ``root`` and be another file than ``baseline``, or else from + ``CORETRACE_BASELINE_KEY``; None when neither is. Trailing line breaks are not part + of the key, from either source.""" + + if key_file is not None: + if _within(key_file, root): + raise BaselineError( + f"{key_file}: the baseline key file is inside the analysed directory; " + "keep it out of the repository" + ) + if baseline is not None and _same(key_file, baseline): + raise BaselineError(f"{key_file}: the baseline key file is the baseline itself") + try: + secret = key_file.read_bytes() + except OSError as error: + raise BaselineError(f"{key_file}: cannot read the baseline key: {error.strerror}") from error + source = str(key_file) + elif KEY_ENVIRONMENT in environ: + secret = environ[KEY_ENVIRONMENT].encode("utf-8", "surrogatepass") + if not secret: + raise BaselineError(f"{KEY_ENVIRONMENT} is set but empty") + source = KEY_ENVIRONMENT + else: + return None + secret = secret.rstrip(b"\r\n") + if len(secret) < _MINIMUM_KEY: + raise BaselineError(f"{source}: the baseline key is shorter than {_MINIMUM_KEY} bytes") + return BaselineKey(secret) + + +def _same(path: Path, other: Path) -> bool: + try: + return path.samefile(other) + except OSError: + return False + + +def _within(path: Path, root: Path) -> bool: + """Whether ``path`` lies in the directory ``root``, compared as files, not as + spellings: letter case, symbolic links and firmlinks do not hide it.""" + + resolved = path.resolve() + return any(_same(candidate, root) for candidate in (resolved, *resolved.parents)) + + +def ensure_recordable(path: Path, key: BaselineKey | None) -> None: + """Refuse to record over a keyed baseline without a key: that would silently turn its + digests back into ones anyone can test offline. Going back to an unkeyed baseline + takes deleting it explicitly.""" + + if key is not None or not path.is_file(): + return + try: + schema = json.loads(path.read_text(encoding="utf-8")).get("schema") + except (OSError, ValueError, AttributeError): + return + if schema == KEYED_SCHEMA: + raise BaselineError( + f"{path}: recorded with a baseline key (schema 4); give it with --baseline-key-file or " + f"{KEY_ENVIRONMENT} to record it again, or delete the file to record an unkeyed baseline" + ) + + +def fingerprint(finding: Finding, root: Path, key: BaselineKey | None = None) -> Fingerprint: + """The fingerprint of ``finding`` in schema 3, or in schema 4 under ``key``.""" -def fingerprint(finding: Finding, root: Path) -> Fingerprint: path, head = _head(finding, root) span = finding.span if isinstance(span, SourceSpan): - return (*head, text_digest(_span_text(path, span)), "") + text = _span_text(path, span) + return (*head, text_digest(text) if key is None else key.digest(text), "") # A key above the value may itself be a secret. - return (*head, span.digest, text_digest(span.pointer)) + if key is None: + return (*head, span.digest, text_digest(span.pointer)) + return (*head, key.digest(span.digest), key.digest(span.pointer)) def legacy_fingerprint(finding: Finding, root: Path) -> Fingerprint | None: @@ -97,25 +210,46 @@ class Baseline: entries: Counter[Fingerprint] = field(default_factory=Counter) # The schema of the file read; its entries are compared by that schema's fingerprint. schema: int = BASELINE_SCHEMA + # The key given to the check, whatever the schema. + key: BaselineKey | None = field(default=None, repr=False, compare=False) @classmethod - def of(cls, findings: Iterable[Finding], root: Path) -> Baseline: - return cls(Counter(fingerprint(finding, root) for finding in findings)) + def of(cls, findings: Iterable[Finding], root: Path, key: BaselineKey | None = None) -> Baseline: + entries = Counter(fingerprint(finding, root, key) for finding in findings) + return cls(entries, BASELINE_SCHEMA if key is None else KEYED_SCHEMA, key) @classmethod - def load(cls, path: Path) -> Baseline: + def load(cls, path: Path, key: BaselineKey | None = None) -> Baseline: try: document = json.loads(path.read_text(encoding="utf-8")) schema = document.get("schema") - if schema not in (*_LEGACY_SCHEMAS, BASELINE_SCHEMA): + if schema not in (*_LEGACY_SCHEMAS, BASELINE_SCHEMA, KEYED_SCHEMA): raise BaselineError(f"{path}: unsupported baseline schema {schema!r}") + if key is not None and schema != KEYED_SCHEMA: + # Else whoever can edit the file could swap the keyed baseline for an + # unkeyed one holding the digest of a new secret. + raise BaselineError( + f"{path}: an unkeyed baseline (schema {schema}) while a baseline key is given; " + "review its findings, then record it in schema 4 with --record-baseline" + ) + if schema == KEYED_SCHEMA: + if key is None: + raise BaselineError( + f"{path}: recorded with a baseline key (schema 4); give it with " + f"--baseline-key-file or {KEY_ENVIRONMENT}" + ) + if not hmac.compare_digest(str(document["key_check"]), key.check): + raise BaselineError( + f"{path}: recorded with another baseline key; to rotate the key, review " + "the findings, then record the baseline again with --record-baseline" + ) entries = Counter( { ( str(e["path"]), str(e["rule"]), str(e["function"]), - str(e["digest"] if schema == BASELINE_SCHEMA else e.get("line", "")), + str(e.get("line", "") if schema in _LEGACY_SCHEMAS else e["digest"]), str(e.get("pointer", "")), ): int(e["count"]) for e in document["findings"] @@ -125,17 +259,40 @@ def load(cls, path: Path) -> Baseline: raise except (OSError, ValueError, KeyError, TypeError, AttributeError) as error: raise BaselineError(f"{path}: {error}") from error - return cls(entries, schema) + return cls(entries, schema, key) def save(self, path: Path) -> None: - document = { - "schema": BASELINE_SCHEMA, - "findings": [ - {"path": p, "rule": r, "function": f, "digest": digest, "pointer": pointer, "count": count} - for (p, r, f, digest, pointer), count in sorted(self.entries.items()) - ], - } - path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + """Write the baseline to ``path`` at once: the file there, if any, is replaced only + when the new one is complete.""" + + document: dict[str, object] = {"schema": self.schema} + if self.schema == KEYED_SCHEMA: + assert self.key is not None + document["key_check"] = self.key.check + document["findings"] = [ + {"path": p, "rule": r, "function": f, "digest": digest, "pointer": pointer, "count": count} + for (p, r, f, digest, pointer), count in sorted(self.entries.items()) + ] + # Through a symbolic link, to its target; with the mode of the file it replaces, or + # the one a new file gets. + target = Path(os.path.realpath(path)) + try: + mode = stat.S_IMODE(target.stat().st_mode) + except FileNotFoundError: + umask = os.umask(0) + os.umask(umask) + mode = 0o666 & ~umask + descriptor, temporary = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent) + try: + os.fchmod(descriptor, mode) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(json.dumps(document, indent=2) + "\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + except BaseException: + Path(temporary).unlink(missing_ok=True) + raise def partition( self, findings: Iterable[Finding], root: Path @@ -143,15 +300,17 @@ def partition( """The findings not in the baseline, and those it accounts for.""" key_of: Callable[[Finding, Path], Fingerprint | None] = ( - fingerprint if self.schema == BASELINE_SCHEMA else legacy_fingerprint + legacy_fingerprint + if self.schema in _LEGACY_SCHEMAS + else partial(fingerprint, key=self.key if self.schema == KEYED_SCHEMA else None) ) remaining = Counter(self.entries) new: list[Finding] = [] baselined: list[Finding] = [] for finding in findings: - key = key_of(finding, root) - if key is not None and remaining[key] > 0: - remaining[key] -= 1 + fingerprinted = key_of(finding, root) + if fingerprinted is not None and remaining[fingerprinted] > 0: + remaining[fingerprinted] -= 1 baselined.append(finding) else: new.append(finding) @@ -160,15 +319,15 @@ def partition( def transition_notice(self) -> str | None: """What to tell when the baseline was read from schema 1 or 2, whose entries hold the text of lines and identify only findings on a single line: it is to be - reviewed and recorded again; None for a schema 3 baseline.""" + reviewed and recorded again; None otherwise.""" - if self.schema == BASELINE_SCHEMA: + if self.schema not in _LEGACY_SCHEMAS: return None return ( f"schema {self.schema} baseline: this baseline may contain secrets in plain text. Its " "entries identify only findings on a single line, so a finding over several lines, or " "in a JSON, TOML or lock file, may be reported new only because no old entry can " "identify it with certainty. Review the baseline and the new findings, then migrate it " - f"explicitly to schema {BASELINE_SCHEMA}: delete it and run the check again, which " - "records digests only" + f"explicitly to schema {BASELINE_SCHEMA} with --record-baseline, which records digests " + "only, or to schema 4 with a baseline key" ) diff --git a/tests/test_baseline_key.py b/tests/test_baseline_key.py new file mode 100644 index 0000000..f9cc695 --- /dev/null +++ b/tests/test_baseline_key.py @@ -0,0 +1,412 @@ +"""Acceptance tests for issue #225: baselines keyed with HMAC-SHA-256. + +A schema 3 baseline holds no text, but its digests let anyone test candidate values +offline. With a key, from a key file given with ``--baseline-key-file`` (kept out of the +analysed directory) or else from ``CORETRACE_BASELINE_KEY``, the baseline is recorded in +schema 4: every digest is an HMAC-SHA-256 under the key, with a check value that tells +the key apart, never the key itself. A schema 4 baseline needs its key: without it, or +with another, the check stops with an explicit error and records nothing; and with a key, +an unkeyed baseline is refused, so that it cannot stand in for the keyed one. Without a +key, schemas 1 to 3 are still read, never rewritten. Rotating the key, or moving to schema 4, is an explicit +``--record-baseline``, which replaces the file only once the check succeeded. The key +never appears in a report, a message or the baseline. +""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path + +import pytest + +from coretrace_python.cli import EXIT_CLEAN, EXIT_ERROR, EXIT_FINDINGS, main +from coretrace_python.source.positions import text_digest + +KEY = "k3y-0f-the-team-" * 2 +OTHER = "an0ther-k3y-2026" * 2 +TOKEN = "ghp_" + "a1B2" * 9 +LINE = f"TOKEN = '{TOKEN}'" +ENVIRONMENT = "CORETRACE_BASELINE_KEY" + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + root = tmp_path / "proj" + root.mkdir() + (root / "app.py").write_text(f"def run(code):\n eval(code)\n\n{LINE}\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + monkeypatch.delenv(ENVIRONMENT, raising=False) + return root + + +def check(capsys: pytest.CaptureFixture[str], *options: str) -> tuple[int, str, str]: + status = main(["--check", "proj", *options]) + captured = capsys.readouterr() + return status, captured.out, captured.err + + +def keyed( + project: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + key: str = KEY, +) -> Path: + baseline = project.parent / "baseline.json" + monkeypatch.setenv(ENVIRONMENT, key) + assert check(capsys, "--baseline", str(baseline))[0] == EXIT_CLEAN + return baseline + + +def test_a_key_records_schema_4_with_hmac_digests_and_never_the_key( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + baseline = keyed(project, monkeypatch, capsys) + text = baseline.read_text(encoding="utf-8") + document = json.loads(text) + + assert document["schema"] == 4 + assert document["key_check"].startswith("hmac-sha256:") + assert all(entry["digest"].startswith("hmac-sha256:") for entry in document["findings"]) + assert KEY not in text and TOKEN not in text + # A candidate value hashed without the key matches no entry. + assert text_digest(LINE) not in text + status, out, err = check(capsys, "--baseline", str(baseline), "--format", "json") + assert status == EXIT_CLEAN + assert KEY not in out and KEY not in err + + +def test_a_schema_4_baseline_without_its_key_is_an_error_and_records_nothing( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + baseline = keyed(project, monkeypatch, capsys) + before = baseline.read_bytes() + monkeypatch.delenv(ENVIRONMENT) + + status, _, err = check(capsys, "--baseline", str(baseline)) + + assert status == EXIT_ERROR + assert "recorded with a baseline key" in err and ENVIRONMENT in err + assert baseline.read_bytes() == before + + +def test_a_schema_4_baseline_with_another_key_is_an_error_and_records_nothing( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + baseline = keyed(project, monkeypatch, capsys) + before = baseline.read_bytes() + monkeypatch.setenv(ENVIRONMENT, OTHER) + + status, _, err = check(capsys, "--baseline", str(baseline)) + + assert status == EXIT_ERROR + assert "another baseline key" in err and "--record-baseline" in err + assert OTHER not in err and KEY not in err + assert baseline.read_bytes() == before + + +def test_the_key_file_given_explicitly_comes_before_the_environment( + project: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + key_file = tmp_path / "secrets" / "baseline.key" + key_file.parent.mkdir() + key_file.write_text(KEY + "\n", encoding="utf-8") + baseline = project.parent / "baseline.json" + monkeypatch.setenv(ENVIRONMENT, OTHER) + assert ( + check(capsys, "--baseline", str(baseline), "--baseline-key-file", str(key_file))[0] + == EXIT_CLEAN + ) + + monkeypatch.setenv(ENVIRONMENT, KEY) + assert check(capsys, "--baseline", str(baseline))[0] == EXIT_CLEAN + monkeypatch.setenv(ENVIRONMENT, OTHER) + assert check(capsys, "--baseline", str(baseline))[0] == EXIT_ERROR + + +@pytest.mark.parametrize( + ("setup", "message"), + [ + ("inside", "inside the analysed directory"), + ("short", "shorter than 16 bytes"), + ("empty", "is set but empty"), + ("missing", "cannot read the baseline key"), + ], +) +def test_an_unusable_key_is_an_error_and_records_nothing( + project: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, + setup: str, + message: str, +) -> None: + baseline = project.parent / "baseline.json" + options = ["--baseline", str(baseline)] + if setup == "inside": + (project / "baseline.key").write_text(KEY, encoding="utf-8") + options += ["--baseline-key-file", str(project / "baseline.key")] + elif setup == "short": + monkeypatch.setenv(ENVIRONMENT, "short") + elif setup == "empty": + monkeypatch.setenv(ENVIRONMENT, "") + else: + options += ["--baseline-key-file", str(tmp_path / "absent.key")] + + status, _, err = check(capsys, *options) + + assert status == EXIT_ERROR + assert message in err and KEY not in err + assert not baseline.exists() + + +def test_rotating_the_key_is_an_explicit_record_that_replaces_the_baseline( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + baseline = keyed(project, monkeypatch, capsys) + monkeypatch.setenv(ENVIRONMENT, OTHER) + assert check(capsys, "--baseline", str(baseline))[0] == EXIT_ERROR + + assert check(capsys, "--baseline", str(baseline), "--record-baseline")[0] == EXIT_CLEAN + + assert check(capsys, "--baseline", str(baseline))[0] == EXIT_CLEAN + monkeypatch.setenv(ENVIRONMENT, KEY) + assert check(capsys, "--baseline", str(baseline))[0] == EXIT_ERROR + assert [path.name for path in baseline.parent.iterdir() if path.name.startswith(".")] == [] + + +def test_a_failed_record_keeps_the_previous_baseline( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + baseline = keyed(project, monkeypatch, capsys) + before = baseline.read_bytes() + monkeypatch.setenv(ENVIRONMENT, "short") + + assert check(capsys, "--baseline", str(baseline), "--record-baseline")[0] == EXIT_ERROR + assert baseline.read_bytes() == before + + +def legacy(path: Path, schema: int) -> Path: + if schema == 3: + entries = [ + { + "path": "app.py", + "rule": "dangerous-eval", + "function": "run", + "digest": text_digest("eval(code)"), + "pointer": "", + "count": 1, + }, + { + "path": "app.py", + "rule": "hardcoded-secret", + "function": "", + "digest": text_digest(LINE), + "pointer": "", + "count": 1, + }, + ] + else: + entries = [ + { + "path": "app.py", + "rule": "dangerous-eval", + "function": "run", + "line": "eval(code)", + "pointer": "", + "count": 1, + }, + { + "path": "app.py", + "rule": "hardcoded-secret", + "function": "", + "line": LINE, + "pointer": "", + "count": 1, + }, + ] + path.write_text(json.dumps({"schema": schema, "findings": entries}), encoding="utf-8") + return path + + +@pytest.mark.parametrize("schema", [1, 2, 3]) +def test_an_unkeyed_baseline_is_refused_while_a_key_is_given( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], schema: int +) -> None: + """Else whoever can edit the baseline could swap the keyed one for an unkeyed one + holding the digest of a new secret.""" + + baseline = legacy(project.parent / "baseline.json", schema) + before = baseline.read_bytes() + monkeypatch.setenv(ENVIRONMENT, KEY) + + status, _, err = check(capsys, "--baseline", str(baseline)) + + assert status == EXIT_ERROR + assert f"unkeyed baseline (schema {schema})" in err and "--record-baseline" in err + assert baseline.read_bytes() == before + + +@pytest.mark.parametrize("schema", [1, 2, 3]) +def test_an_unkeyed_baseline_moves_to_schema_4_by_an_explicit_record( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], schema: int +) -> None: + baseline = legacy(project.parent / "baseline.json", schema) + monkeypatch.setenv(ENVIRONMENT, KEY) + + assert check(capsys, "--baseline", str(baseline), "--record-baseline")[0] == EXIT_CLEAN + + assert json.loads(baseline.read_text(encoding="utf-8"))["schema"] == 4 + assert check(capsys, "--baseline", str(baseline))[0] == EXIT_CLEAN + + +@pytest.mark.parametrize("schema", [1, 2]) +def test_an_old_baseline_without_a_key_is_read_as_before( + project: Path, capsys: pytest.CaptureFixture[str], schema: int +) -> None: + baseline = legacy(project.parent / "baseline.json", schema) + before = baseline.read_bytes() + + status, out, err = check(capsys, "--baseline", str(baseline)) + + assert status == EXIT_CLEAN + assert out.startswith("no findings, 2 baselined\n") + assert f"schema {schema} baseline" in err and "--record-baseline" in err + assert baseline.read_bytes() == before + + +def test_a_schema_3_baseline_without_a_key_keeps_its_behaviour( + project: Path, capsys: pytest.CaptureFixture[str] +) -> None: + baseline = legacy(project.parent / "baseline.json", 3) + + status, out, err = check(capsys, "--baseline", str(baseline)) + + assert (status, out.startswith("no findings, 2 baselined\n"), err) == (EXIT_CLEAN, True, "") + + +def test_a_new_finding_is_still_new_under_a_key( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + baseline = keyed(project, monkeypatch, capsys) + (project / "app.py").write_text( + f"def run(code):\n eval(code)\n\n{LINE.replace('a1B2', 'Z9y8')}\n", encoding="utf-8" + ) + + status, out, _ = check(capsys, "--baseline", str(baseline)) + + assert status == EXIT_FINDINGS + assert out.startswith("app.py:4:9: high hardcoded-secret") + + +def test_recording_without_the_key_never_drops_it( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A CI job whose secret is missing must not turn a keyed baseline into an unkeyed one.""" + + baseline = keyed(project, monkeypatch, capsys) + before = baseline.read_bytes() + monkeypatch.delenv(ENVIRONMENT) + + status, _, err = check(capsys, "--baseline", str(baseline), "--record-baseline") + + assert status == EXIT_ERROR + assert "recorded with a baseline key" in err + assert baseline.read_bytes() == before + + +def test_the_key_file_cannot_be_the_baseline( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + baseline = keyed(project, monkeypatch, capsys) + before = baseline.read_bytes() + + status, _, err = check( + capsys, + "--baseline", + str(baseline), + "--baseline-key-file", + str(baseline), + "--record-baseline", + ) + + assert status == EXIT_ERROR and "the baseline itself" in err + assert baseline.read_bytes() == before + + +def test_a_key_file_inside_is_refused_however_its_path_is_spelled( + project: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + (project / "baseline.key").write_text(KEY, encoding="utf-8") + spelled = tmp_path / project.name.upper() / "baseline.key" + if not spelled.exists(): + pytest.skip("the file system tells letter cases apart") + + status, _, err = check( + capsys, "--baseline", str(project.parent / "b.json"), "--baseline-key-file", str(spelled) + ) + + assert status == EXIT_ERROR and "inside the analysed directory" in err + + +def test_the_same_key_reads_the_same_from_the_file_and_the_environment( + project: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + key_file = tmp_path / "baseline.key" + key_file.write_text(KEY + "\r\n", encoding="utf-8") + baseline = project.parent / "baseline.json" + assert ( + check(capsys, "--baseline", str(baseline), "--baseline-key-file", str(key_file))[0] + == EXIT_CLEAN + ) + + monkeypatch.setenv(ENVIRONMENT, KEY + "\n") + assert check(capsys, "--baseline", str(baseline))[0] == EXIT_CLEAN + + +def test_the_baseline_is_replaced_only_once_the_report_is_rendered( + project: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + baseline = keyed(project, monkeypatch, capsys) + before = baseline.read_bytes() + monkeypatch.setenv(ENVIRONMENT, OTHER) + + def failing(*args: object) -> str: + raise OSError("the report cannot be written") + + monkeypatch.setattr("coretrace_python.cli.render", failing) + + assert check(capsys, "--baseline", str(baseline), "--record-baseline")[0] == EXIT_ERROR + assert baseline.read_bytes() == before + + +def test_a_recorded_baseline_keeps_the_permissions_of_the_file_it_replaces( + project: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + tmp_path: Path, +) -> None: + fresh = project.parent / "fresh.json" + assert check(capsys, "--baseline", str(fresh))[0] == EXIT_CLEAN + umask = os.umask(0) + os.umask(umask) + assert stat.S_IMODE(fresh.stat().st_mode) == 0o666 & ~umask + + fresh.chmod(0o640) + assert check(capsys, "--baseline", str(fresh), "--record-baseline")[0] == EXIT_CLEAN + assert stat.S_IMODE(fresh.stat().st_mode) == 0o640 + + link = project.parent / "link.json" + link.symlink_to(fresh) + assert check(capsys, "--baseline", str(link), "--record-baseline")[0] == EXIT_CLEAN + assert link.is_symlink()