|
27 | 27 | from __future__ import annotations |
28 | 28 |
|
29 | 29 | import ast |
| 30 | +import datetime as _dt |
30 | 31 | import re |
31 | 32 | from functools import lru_cache |
32 | 33 | from pathlib import Path |
@@ -1498,3 +1499,98 @@ def summarize(counts: Dict[str, int], deployment_mode: str = "on_prem") -> str: |
1498 | 1499 | and not counts.get("modules_not_run")): |
1499 | 1500 | parts.append("Coverage is complete.") |
1500 | 1501 | return " ".join(parts) |
| 1502 | + |
| 1503 | + |
| 1504 | +# ── how old the evidence is ──────────────────────────────────────────────── |
| 1505 | +# |
| 1506 | +#: How old an export may be before its answers are called stale. |
| 1507 | +#: |
| 1508 | +#: NOT AN INVENTED NUMBER. SAP publishes Security Notes on Security Patch Day, |
| 1509 | +#: the second Tuesday of each month. An export older than one full cycle cannot |
| 1510 | +#: account for a patch day that has since passed, so verdicts drawn from it are |
| 1511 | +#: answers about a system that no longer exists in that form. 35 days is one |
| 1512 | +#: cycle plus the slack between two second-Tuesdays, which fall 28 to 35 days |
| 1513 | +#: apart. |
| 1514 | +#: |
| 1515 | +#: Defined here rather than in `server/` so the offline report and the console |
| 1516 | +#: cannot drift apart about what "old" means — `server.queries` imports it. |
| 1517 | +STALE_AFTER_DAYS = 35 |
| 1518 | + |
| 1519 | + |
| 1520 | +def evidence_age(manifest: Optional[Iterable[Dict[str, Any]]], |
| 1521 | + now: Optional[_dt.datetime] = None) -> Optional[Dict[str, Any]]: |
| 1522 | + """When the exports behind a report were last written. |
| 1523 | +
|
| 1524 | + THE NUMBER IS A LOWER BOUND, AND THAT IS THE WHOLE POINT OF THIS FUNCTION. |
| 1525 | + It is derived from each file's modification time, which is when the file was |
| 1526 | + last WRITTEN on the machine that produced the bundle. Copying a directory, |
| 1527 | + unzipping an archive or exporting through a share usually resets that to the |
| 1528 | + moment of the copy — so a file's mtime can only ever be LATER than the |
| 1529 | + moment the data was really taken out of SAP, never earlier. |
| 1530 | +
|
| 1531 | + Which makes the reading asymmetric, and the report has to say so: |
| 1532 | +
|
| 1533 | + * "at least 240 days old" is sound. Nothing can make evidence look older |
| 1534 | + than it is, so a large figure is a floor and can be acted on. |
| 1535 | + * "0 days old" says nothing at all. The files may have been copied this |
| 1536 | + morning out of an export taken last year. |
| 1537 | +
|
| 1538 | + So this reports the floor and never reassures. `stale` is True only when the |
| 1539 | + FLOOR exceeds the threshold; it is never False in a way that means "fresh", |
| 1540 | + only in a way that means "this cannot tell you". |
| 1541 | +
|
| 1542 | + Returns None when no entry carries a usable timestamp, which is a different |
| 1543 | + state from "the evidence is new" and must not render as one. |
| 1544 | + """ |
| 1545 | + entries = list(manifest or []) |
| 1546 | + now = now or _dt.datetime.now() |
| 1547 | + stamps: List[_dt.datetime] = [] |
| 1548 | + for entry in entries: |
| 1549 | + raw = str((entry or {}).get("modified") or "").strip() |
| 1550 | + if not raw: |
| 1551 | + continue |
| 1552 | + for shape in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"): |
| 1553 | + try: |
| 1554 | + stamps.append(_dt.datetime.strptime(raw[:len(shape) + 4], shape)) |
| 1555 | + break |
| 1556 | + except ValueError: |
| 1557 | + continue |
| 1558 | + if not stamps: |
| 1559 | + return None |
| 1560 | + |
| 1561 | + oldest, newest = min(stamps), max(stamps) |
| 1562 | + # The NEWEST file is what dates the bundle: an export directory is written |
| 1563 | + # in one sitting, and one stale leftover among a hundred current files does |
| 1564 | + # not make the assessment old. The oldest is reported beside it so a bundle |
| 1565 | + # assembled over eight months is visible as one. |
| 1566 | + floor_days = max(0, (now - newest).days) |
| 1567 | + return { |
| 1568 | + "files": len(stamps), |
| 1569 | + "oldest": oldest.strftime("%Y-%m-%d"), |
| 1570 | + "newest": newest.strftime("%Y-%m-%d"), |
| 1571 | + "span_days": max(0, (newest - oldest).days), |
| 1572 | + "at_least_days": floor_days, |
| 1573 | + "stale_after_days": STALE_AFTER_DAYS, |
| 1574 | + "stale": floor_days > STALE_AFTER_DAYS, |
| 1575 | + } |
| 1576 | + |
| 1577 | + |
| 1578 | +def evidence_age_sentence(age: Optional[Dict[str, Any]]) -> str: |
| 1579 | + """The one line a reader should see, or nothing. |
| 1580 | +
|
| 1581 | + Silent when the floor is small, because a small floor is not evidence of |
| 1582 | + freshness — see `evidence_age`. A reassuring sentence built on a number |
| 1583 | + that cannot reassure is worse than no sentence. |
| 1584 | + """ |
| 1585 | + if not age: |
| 1586 | + return "" |
| 1587 | + if not age["stale"]: |
| 1588 | + return "" |
| 1589 | + return ( |
| 1590 | + "The exports behind this report were last written on or before " |
| 1591 | + "%s — at least %d days ago, more than the %d-day SAP Security Patch " |
| 1592 | + "Day cycle. Findings here describe the system as it was then. A file's " |
| 1593 | + "timestamp can only ever be later than the moment the data left SAP, " |
| 1594 | + "so this is a floor: the evidence may be older still." |
| 1595 | + % (age["newest"], age["at_least_days"], age["stale_after_days"]) |
| 1596 | + ) |
0 commit comments