diff --git a/README.md b/README.md index 5432a30..e6ff8c7 100644 --- a/README.md +++ b/README.md @@ -144,3 +144,12 @@ Dual licensed. It's free under [AGPL-3.0](LICENSE) for research, education, and CompleteTech LLC
Built by CompleteTech LLC · Innovation at Every Integration

+ + +### Standalone report provenance + +New runs include a `provenance` object in JSON and durable checkpoints with `tool`, `tool_version`, `executable_sha256`, `rules_sha256`, `rules_as_of`, and UTC `generated_at`. CSV appends the same fields to every result or empty summary, after existing completeness columns; HTML displays them separately from per-result observation time. The executable digest identifies the exact standalone file bytes (including when launched through a symlink); the rules digest identifies the embedded fix table, CVEs, date and sources, without claiming deployment attestation. + +Recovery preserves the original acquisition identity and generation time, even using a changed checker. Older checkpoints remain readable but JSON has `provenance: null` and CSV/HTML label unavailable identity as `unknown`; recovery never attributes old evidence to its current executable. Per-row `checked_at`, rules verification date and run generation time retain distinct meanings. Existing complete/incomplete counts and `exploitability=not_tested` semantics remain unchanged. + +When rendering an older checkpoint, HTML explicitly labels the rendering checker's fix table and source links as **current reference guidance** with their own rule date. They do not reclassify the preserved results or replace the acquisition rule date/digest. diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 62aba4a..bc7349e 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -353,3 +353,12 @@ The WordPress REST marker follows WordPress's documented ## License Dual licensed under [AGPL-3.0](../LICENSE) or a commercial license; see [LICENSING.md](../LICENSING.md). + + +### Standalone report provenance + +New runs include a `provenance` object in JSON and durable checkpoints with `tool`, `tool_version`, `executable_sha256`, `rules_sha256`, `rules_as_of`, and UTC `generated_at`. CSV appends the same fields to every result or empty summary, after existing completeness columns; HTML displays them separately from per-result observation time. The executable digest identifies the exact standalone file bytes (including when launched through a symlink); the rules digest identifies the embedded fix table, CVEs, date and sources, without claiming deployment attestation. + +Recovery preserves the original acquisition identity and generation time, even using a changed checker. Older checkpoints remain readable but JSON has `provenance: null` and CSV/HTML label unavailable identity as `unknown`; recovery never attributes old evidence to its current executable. Per-row `checked_at`, rules verification date and run generation time retain distinct meanings. Existing complete/incomplete counts and `exploitability=not_tested` semantics remain unchanged. + +When rendering an older checkpoint, HTML explicitly labels the rendering checker's fix table and source links as **current reference guidance** with their own rule date. They do not reclassify the preserved results or replace the acquisition rule date/digest. diff --git a/docs/example-report.html b/docs/example-report.html index 0ee999e..db0e8d1 100644 --- a/docs/example-report.html +++ b/docs/example-report.html @@ -32,16 +32,17 @@
CompleteTech LLC logo
CompleteTech LLC

PHP2Shell Exposure Report

-

CVE-2026-109618 & CVE-2026-109619 · generated 2026-10-21 14:00 UTC · rules as of 2026-10-21

+

CVE-2026-109618 & CVE-2026-109619 · acquisition generated 2026-10-21 14:00 UTC · acquisition rules as of 2026-10-21

6Websites checked
2Potentially affected
2Fixed version reported
2Unknown / not disclosed
3WordPress detected
-

Run complete: 6 completed; 0 pending; 6 total targets.

Results

+

Run complete: 6 completed; 0 pending; 6 total targets.

Acquisition provenance: tool=php2shell-check; tool_version=1.0.0; executable_sha256=994d58784788a6c93786b0e391a02aa97e5b50f4e8724aa18916cbe3538527ef; rules_sha256=ce910c950455a08ea4ae845af4c6453a14f5e8d28aec47c229b8d815131ff655; rules_as_of=2026-10-21; generated_at=2026-10-21T14:00:00+00:00

Results

WebsitePHP2ShellPHP reportedUpstream fixDetectionHTTPNotes
https://example.com/Potentially affected8.3.358.3.36wordpress200Advertised PHP 8.3.35 is older than the 8.3.36 upstream fix for both listed CVEs.
https://shop.example.org/Potentially affected8.2.348.2.35no_evidence200Advertised PHP 8.2.34 is older than the 8.2.35 upstream fix for both listed CVEs.
https://blog.example.com/Unknown8.3.6-1ubuntu0.24.04.138.3.36wordpress200PHP 8.3.6-1ubuntu0.24.04.13 contains a build qualifier. Confirm the exact vendor package; its upstream base alone cannot establish patch status.
https://www.example.edu/Unknown——no_evidence200No public PHP version was disclosed; lack of a header does not establish that PHP is absent.
https://example.net/Fixed version reported8.4.278.4.27wordpress200Advertised PHP 8.4.27 is at or above the 8.4.27 upstream fix. This is not a verified clean bill of health.
https://portal.example.org/Fixed version reported8.5.128.5.12no_evidence200Advertised PHP 8.5.12 is at or above the 8.5.12 upstream fix. This is not a verified clean bill of health.
-

Upstream fix thresholds

+

Current reference fix thresholds

+

Current reference rules as of 2026-10-21. This table and the current source links are guidance from the rendering checker; they do not reclassify preserved acquisition results.

BranchFixed in
8.28.2.35
8.38.3.36
8.48.4.27
8.58.5.12

How to read this report

Potential exposure is not confirmed exploitability. Results compare PHP versions that servers advertise in public headers with upstream fix releases. No exploit payloads were sent. Distributions can backport fixes without changing the version number, and hidden versions are reported as unknown, not safe. Administrators should verify the installed package and runtime.

- +
diff --git a/skills/php2shell-check/SKILL.md b/skills/php2shell-check/SKILL.md index 7f61ac4..9f7fda4 100644 --- a/skills/php2shell-check/SKILL.md +++ b/skills/php2shell-check/SKILL.md @@ -153,3 +153,12 @@ python -c "import json; from wordpress_check import assess_php2shell; print(json Label this as offline version interpretation, not a live observation. For conflicting supplied values, pass all values through `version_candidates` instead of selecting one; do not invent a version when none was disclosed. + + +### Standalone report provenance + +New runs include a `provenance` object in JSON and durable checkpoints with `tool`, `tool_version`, `executable_sha256`, `rules_sha256`, `rules_as_of`, and UTC `generated_at`. CSV appends the same fields to every result or empty summary, after existing completeness columns; HTML displays them separately from per-result observation time. The executable digest identifies the exact standalone file bytes (including when launched through a symlink); the rules digest identifies the embedded fix table, CVEs, date and sources, without claiming deployment attestation. + +Recovery preserves the original acquisition identity and generation time, even using a changed checker. Older checkpoints remain readable but JSON has `provenance: null` and CSV/HTML label unavailable identity as `unknown`; recovery never attributes old evidence to its current executable. Per-row `checked_at`, rules verification date and run generation time retain distinct meanings. Existing complete/incomplete counts and `exploitability=not_tested` semantics remain unchanged. + +When rendering an older checkpoint, HTML explicitly labels the rendering checker's fix table and source links as **current reference guidance** with their own rule date. They do not reclassify the preserved results or replace the acquisition rule date/digest. diff --git a/test_report_identity.py b/test_report_identity.py new file mode 100644 index 0000000..a089caf --- /dev/null +++ b/test_report_identity.py @@ -0,0 +1,104 @@ +import argparse +import csv +import hashlib +import importlib.util +import io +import json +import shutil +import tempfile +import unittest +from contextlib import redirect_stdout, redirect_stderr +from pathlib import Path +from unittest.mock import patch +import wordpress_check as m + + +class ReportIdentityTests(unittest.TestCase): + def args(self, fmt): + return argparse.Namespace(format=fmt, logo=Path('absent-logo.png'), brand='Synthetic') + + def test_all_formats_preserve_one_identity_and_distinct_times(self): + row = m.asdict(m.Result(input_url='https://example.invalid/', checked_at='2001-01-01T00:00:00+00:00')) + report = m.run_report([row], 1, complete=True) + expected = hashlib.sha256(Path(m.__file__).read_bytes()).hexdigest() + self.assertEqual(report['provenance']['executable_sha256'], expected) + for fmt in ('json', 'csv', 'html'): + output = m.format_report(report, self.args(fmt)) + for value in report['provenance'].values(): + self.assertIn(value, output) + self.assertIn(row['checked_at'], output) + self.assertNotEqual(report['provenance']['generated_at'], row['checked_at']) + + def test_empty_incomplete_and_recovered_reports_retain_acquisition_identity(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory)/'checkpoint.json' + report = m.run_report([], 2) + path.write_text(m.checkpoint_json(report), encoding='utf-8') + with patch.object(m, 'report_provenance', side_effect=AssertionError('must not replace acquisition identity')): + restored = m.load_checkpoint(path) + for fmt in ('json', 'csv', 'html'): + output = m.format_report(restored, self.args(fmt)) + self.assertIn(report['provenance']['executable_sha256'], output) + row = next(csv.DictReader(io.StringIO(m.format_report(restored, self.args('csv'))))) + self.assertEqual(row['record_type'], 'summary') + self.assertEqual(row['complete'], 'False') + self.assertEqual(row['pending_targets'], '2') + + def test_cli_interrupt_keeps_initial_run_identity(self): + result = m.Result(input_url='https://one.invalid/') + identity = m.report_provenance() + with patch.object(m.Checker, 'check', side_effect=[result, KeyboardInterrupt]), patch.object(m, 'report_provenance', return_value=identity) as provenance, redirect_stdout(io.StringIO()) as out, redirect_stderr(io.StringIO()): + self.assertEqual(m.main(['one.invalid', 'two.invalid', '--format', 'json']), 130) + report = json.loads(out.getvalue()) + self.assertEqual(report['provenance'], identity) + self.assertEqual(provenance.call_count, 1) + self.assertFalse(report['complete']) + self.assertEqual(report['completed_targets'], 1) + + def test_recovery_distinguishes_updated_reference_rules_from_acquisition(self): + report = m.run_report([m.asdict(m.Result(input_url='old.invalid', php2shell_fixed_version='8.2.35'))], 1) + original = dict(report['provenance']) + with patch.object(m, 'PHP2SHELL_RULES_DATE', '2099-01-01'), patch.object(m, 'PHP2SHELL_FIXES', {(8, 2): 99}), patch.object(m, 'PHP2SHELL_SOURCES', ('https://reference.invalid/new',)): + text = m.format_report(report, self.args('html')) + self.assertIn('acquisition rules as of ' + original['rules_as_of'], text) + self.assertIn('Current reference rules as of 2099-01-01', text) + self.assertIn('8.2.99', text) + self.assertIn('8.2.35', text) + self.assertIn('Current reference sources:', text) + for fmt in ('json', 'csv'): + self.assertIn(original['rules_sha256'], m.format_report(report, self.args(fmt))) + self.assertEqual(report['provenance'], original) + + def test_copied_and_symlinked_executable_identity(self): + with tempfile.TemporaryDirectory() as directory: + copy = Path(directory)/'standalone.py' + shutil.copyfile(m.__file__, copy) + copy.write_bytes(copy.read_bytes()+b'\n# synthetic distribution variant\n') + with patch.object(m, '__file__', str(copy)): + self.assertEqual(m.report_provenance()['executable_sha256'], hashlib.sha256(copy.read_bytes()).hexdigest()) + link = Path(directory)/'link.py' + try: + link.symlink_to(copy) + except OSError: + return + with patch.object(m, '__file__', str(link)): + self.assertEqual(m.report_provenance()['executable_sha256'], hashlib.sha256(copy.read_bytes()).hexdigest()) + + def test_legacy_checkpoint_is_not_relabelled_and_invalid_provenance_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory)/'checkpoint.json' + report = m.run_report([], 1) + legacy = dict(report) + del legacy['provenance'] + path.write_text(json.dumps(legacy), encoding='utf-8') + restored = m.load_checkpoint(path) + self.assertIsNone(json.loads(m.format_report(restored, self.args('json')))['provenance']) + self.assertIn('unknown', m.format_report(restored, self.args('csv'))) + for key, value in [('executable_sha256', '=unsafe'), ('generated_at', '2026-01-01T00:00:00'), ('tool', '')]: + bad = {**report, 'provenance': {**report['provenance'], key: value}} + path.write_text(json.dumps(bad), encoding='utf-8') + with self.assertRaises(ValueError): + m.load_checkpoint(path) + +if __name__ == '__main__': + unittest.main() diff --git a/wordpress_check.py b/wordpress_check.py index 6606b14..b20f3de 100644 --- a/wordpress_check.py +++ b/wordpress_check.py @@ -9,6 +9,7 @@ import base64 import csv import html +import hashlib import io import os import stat @@ -31,6 +32,8 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener +TOOL_NAME = "php2shell-check" +TOOL_VERSION = "1.0.0" BOT = "WordPressPublicChecker" REDIRECTS = {301, 302, 303, 307, 308} MAX_BODY = 2 * 1024 * 1024 @@ -673,7 +676,7 @@ def logo_data_uri(path) -> str: return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" -def render_html(rows, *, logo=DEFAULT_LOGO, brand="CompleteTech LLC", generated_at=None) -> str: +def render_html(rows, *, logo=DEFAULT_LOGO, brand="CompleteTech LLC", generated_at=None, rules_as_of=PHP2SHELL_RULES_DATE) -> str: """Render result dictionaries as a self-contained, script-free HTML report.""" def esc(value): return html.escape("" if value is None else str(value)) @@ -694,7 +697,7 @@ def esc(value): notes = row.get("php2shell_notes") or [] body.append( "" - f"{esc(row.get('final_url') or row.get('input_url'))}" + f"{esc(row.get('final_url') or row.get('input_url'))}
Observed: {esc(row.get('checked_at') or 'unknown')}" f"{esc(STATUS_LABELS.get(status, status))}" f"{esc(row.get('php_version') or '—')}" f"{esc(row.get('php2shell_fixed_version') or '—')}" @@ -715,18 +718,19 @@ def esc(value): PHP2Shell Exposure Report
{logo_html}
{esc(brand)}

PHP2Shell Exposure Report

-

{' & '.join(PHP2SHELL_CVES)} · generated {esc(generated_at)} · rules as of {PHP2SHELL_RULES_DATE}

+

Current reference CVEs: {' & '.join(PHP2SHELL_CVES)} · acquisition generated {esc(generated_at)} · acquisition rules as of {esc(rules_as_of)}

{tile_html}

Results

{''.join(body) or empty}
WebsitePHP2ShellPHP reportedUpstream fixDetectionHTTPNotes
-

Upstream fix thresholds

+

Current reference fix thresholds

+

Current reference rules as of {esc(PHP2SHELL_RULES_DATE)}. This table and the current source links are guidance from the rendering checker; they do not reclassify preserved acquisition results.

{fixes}
BranchFixed in

How to read this report

Potential exposure is not confirmed exploitability. Results compare PHP versions that servers advertise in public headers with upstream fix releases. No exploit payloads were sent. Distributions can backport fixes without changing the version number, and hidden versions are reported as unknown, not safe. Administrators should verify the installed package and runtime.

-
{esc(brand)} · Generated by php2shell-check · Sources: {sources}
+
{esc(brand)} · Generated by php2shell-check · Current reference sources: {sources}
""" @@ -760,8 +764,52 @@ def publish_report(path, text, *, replace=False): temporary.unlink(missing_ok=True) -def run_report(rows, total, *, complete=False): - return {"journal_schema": "php2shell-run-v1", "complete": complete, +def report_provenance(): + # Resolve the executing file only; evidence/checkpoint symlink policy is unchanged. + source = Path(__file__).resolve(strict=True) + descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)) + try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise ValueError("Checker identity requires a regular executable file") + with os.fdopen(descriptor, "rb", closefd=False) as stream: + data = stream.read(16 * 1024 * 1024 + 1) + finally: + os.close(descriptor) + if len(data) > 16 * 1024 * 1024: + raise ValueError("Checker executable exceeds identity size limit") + rules = {"as_of": PHP2SHELL_RULES_DATE, "cves": PHP2SHELL_CVES, + "fixes": sorted((list(key), value) for key, value in PHP2SHELL_FIXES.items()), + "sources": PHP2SHELL_SOURCES} + return {"tool": TOOL_NAME, "tool_version": TOOL_VERSION, + "executable_sha256": hashlib.sha256(data).hexdigest(), + "rules_sha256": hashlib.sha256(json.dumps(rules, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest(), + "rules_as_of": PHP2SHELL_RULES_DATE, + "generated_at": datetime.now(timezone.utc).isoformat()} + + +def validate_provenance(value): + if not isinstance(value, dict) or set(value) != {"tool", "tool_version", "executable_sha256", "rules_sha256", "rules_as_of", "generated_at"}: + raise ValueError("Invalid checkpoint provenance fields") + if value["tool"] != TOOL_NAME or not isinstance(value["tool_version"], str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", value["tool_version"]): + raise ValueError("Invalid checkpoint tool identity") + for key in ("executable_sha256", "rules_sha256"): + if not isinstance(value[key], str) or not re.fullmatch(r"[0-9a-f]{64}", value[key]): + raise ValueError("Invalid checkpoint digest") + for key in ("rules_as_of", "generated_at"): + if not isinstance(value[key], str): + raise ValueError("Invalid checkpoint provenance date") + if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value["rules_as_of"]): + raise ValueError("Invalid checkpoint rules date") + try: + stamp = datetime.fromisoformat(value["generated_at"]) + except ValueError as exc: + raise ValueError("Invalid checkpoint generation time") from exc + if stamp.utcoffset() is None or stamp.utcoffset().total_seconds() != 0: + raise ValueError("Checkpoint generation time must be UTC") + + +def run_report(rows, total, *, complete=False, provenance=None): + return {"journal_schema": "php2shell-run-v1", "provenance": report_provenance() if provenance is None else provenance, "complete": complete, "completed_targets": len(rows), "pending_targets": total - len(rows), "total_targets": total, "results": list(rows)} @@ -781,8 +829,10 @@ def load_checkpoint(path): if len(raw) > MAX_CHECKPOINT_BYTES: raise ValueError("Checkpoint exceeds 64 MiB recovery limit") report = json.loads(raw) - if not isinstance(report, dict) or set(report) != {"journal_schema", "complete", "completed_targets", "pending_targets", "total_targets", "results"}: + if not isinstance(report, dict) or set(report) not in ({"journal_schema", "complete", "completed_targets", "pending_targets", "total_targets", "results"}, {"journal_schema", "provenance", "complete", "completed_targets", "pending_targets", "total_targets", "results"}): raise ValueError("Invalid checkpoint fields") + if "provenance" in report: + validate_provenance(report["provenance"]) rows = report["results"] if (report["journal_schema"] != "php2shell-run-v1" or type(report["complete"]) is not bool or not isinstance(rows, list) or len(rows) > MAX_CHECKPOINT_ROWS @@ -812,21 +862,26 @@ def load_checkpoint(path): def format_report(report, args): metadata = {key: report[key] for key in ("complete", "completed_targets", "pending_targets", "total_targets")} + provenance = report.get("provenance") + # Legacy journals have no acquisition identity; never attribute them to this executable. + identity = provenance or {"tool": "unknown", "tool_version": "unknown", "executable_sha256": "unknown", + "rules_sha256": "unknown", "rules_as_of": "unknown", "generated_at": "unknown"} if args.format == "json": - return json.dumps({**metadata, "results": report["results"]}, ensure_ascii=False, indent=2) + "\n" + return json.dumps({**metadata, "provenance": provenance, "results": report["results"]}, ensure_ascii=False, indent=2) + "\n" if args.format == "html": - rendered = render_html(report["results"], logo=args.logo, brand=args.brand) + rendered = render_html(report["results"], logo=args.logo, brand=args.brand, generated_at=identity["generated_at"], rules_as_of=identity["rules_as_of"]) state = "complete" if report["complete"] else "incomplete / recovered" banner = f"

Run {state}: {report['completed_targets']} completed; {report['pending_targets']} pending; {report['total_targets']} total targets.

" - return rendered.replace('

Results

', banner + '

Results

', 1) + details = "

Acquisition provenance: " + "; ".join(html.escape(key + "=" + str(value)) for key, value in identity.items()) + "

" + return rendered.replace('

Results

', banner + details + '

Results

', 1) stream = io.StringIO(newline="") - fields = list(Result.__dataclass_fields__) + ["record_type", *metadata] + fields = list(Result.__dataclass_fields__) + ["record_type", *metadata, *identity] writer = csv.DictWriter(stream, fieldnames=fields) writer.writeheader() for row in report["results"]: - writer.writerow({key: csv_safe(value) for key, value in {**row, "record_type": "result", **metadata}.items()}) + writer.writerow({key: csv_safe(value) for key, value in {**row, "record_type": "result", **metadata, **identity}.items()}) if not report["results"]: - writer.writerow({"record_type": "summary", **metadata}) + writer.writerow({key: csv_safe(value) for key, value in {"record_type": "summary", **metadata, **identity}.items()}) return stream.getvalue() @@ -883,7 +938,7 @@ def main(argv=None) -> int: try: for index, url in enumerate(urls): result = checker.check(url) - report = run_report([*report["results"], asdict(result)], len(urls)) + report = run_report([*report["results"], asdict(result)], len(urls), provenance=report["provenance"]) if checkpoint: publish_report(checkpoint, checkpoint_json(report), replace=True) print(f"[{index + 1}/{len(urls)}] {url}: {result.status}", file=sys.stderr)