Skip to content
Merged
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,12 @@ Dual licensed. It's free under [AGPL-3.0](LICENSE) for research, education, and
<a href="https://complete.tech"><img src="assets/logo.png" alt="CompleteTech LLC" width="160"></a><br>
<sub>Built by <a href="https://complete.tech">CompleteTech LLC</a> · Innovation at Every Integration</sub>
</p>


### 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.
9 changes: 9 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 5 additions & 4 deletions docs/example-report.html

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions skills/php2shell-check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
104 changes: 104 additions & 0 deletions test_report_identity.py
Original file line number Diff line number Diff line change
@@ -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', '<unsafe>')]:
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()
Loading
Loading