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
8 changes: 8 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ on:
paths:
- "landing/**"
- "scripts/validate-product-source.py"
- "scripts/audit-public-media.py"
- "scripts/test-audit-public-media.py"
- "scripts/build-product-site.py"
- "scripts/validate-product-build.py"
- "scripts/apply-search-authority.py"
Expand All @@ -30,6 +32,8 @@ on:
paths:
- "landing/**"
- "scripts/validate-product-source.py"
- "scripts/audit-public-media.py"
- "scripts/test-audit-public-media.py"
- "scripts/build-product-site.py"
- "scripts/validate-product-build.py"
- "scripts/apply-search-authority.py"
Expand Down Expand Up @@ -122,9 +126,12 @@ jobs:
continue-on-error: true
shell: bash
run: |
set -euo pipefail
mkdir -p _validation
python scripts/validate-product-source.py > _validation/source.log 2>&1
python scripts/validate-adoption-proof.py >> _validation/source.log 2>&1
python scripts/test-audit-public-media.py >> _validation/source.log 2>&1
python scripts/audit-public-media.py --report _validation/media-audit.json >> _validation/source.log 2>&1

- name: Upload source validation report
if: always()
Expand All @@ -133,6 +140,7 @@ jobs:
name: product-source-validation
path: |
_validation/source.log
_validation/media-audit.json
_validation/site-source.json
if-no-files-found: error

Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/publish-verified-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ jobs:
raise SystemExit('Missing publication request fields: ' + ', '.join(missing))
if not re.fullmatch(r'\d+\.\d+\.\d+', str(request['version'])):
raise SystemExit('Publication request version is invalid')
# This historical artifact publisher expects a portable ZIP. Modern stable
# releases require the exact tested single EXE with SBOM/provenance from
# release-windows.yml. Prevent any newer release from silently reverting.
if tuple(map(int, request['version'].split('.'))) >= (1, 6, 42):
raise SystemExit('Modern single-EXE release must use release-windows.yml; legacy ZIP publisher refuses this version')
if notes.get('version') != request['version']:
raise SystemExit('Release notes version does not match publication request')
signing = notes.get('codeSigning', {})
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/release-supply-chain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ jobs:
exit 1
fi
version="${tag#v}"
# This additive backfill extracts a historical ZIP to produce a file-level SBOM.
# Modern public releases (v1.6.42+) ship a portable single EXE. They require
# the canonical release-windows.yml provenance/SBOM flow; never claim that
# the EXE is an extractable archive or silently substitute historical bytes.
python - "$version" <<'PY'
import sys
version = tuple(map(int, sys.argv[1].split(".")[:3]))
if version >= (1, 6, 42):
raise SystemExit("Single-EXE stable release: use the canonical release SBOM/provenance workflow; legacy ZIP backfill is not applicable.")
PY
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"

Expand Down
45 changes: 45 additions & 0 deletions docs/PUBLIC_PRODUCT_P2_RELEASE_AND_MEDIA_REVIEW_2026-10-25.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ARSAS Public Release and Screenshot Review — 2026-10-25

## Scope and immutable boundaries

This checkpoint follows the accepted public **v1.6.42** release and the merged P0/P1 landing work (#742/#743). It does **not** repackage the application, repoint `v1.6.42`, repin ARIEC61850, rewrite the Windows executable, certify IEC 61850 conformance, or approve physical R7/R10 promotion (#729).

**Source of truth for a downloadable release:** the published GitHub stable tag and immutable release assets, mirrored to `landing/latest.json` and `.release/published.json`. A different version on main/VERSION is intentionally possible pending physical authority. Do not change the stable ledgers to match an unqualified application tree.

## P2A — release contract hardened

- The public stable Portable artifact is `ARSAS-Windows-x64-Portable.exe` for **v1.6.42 and later**. Older *tag-specific* release ZIP links may be recognized as historical but must never be presented as the latest stable asset.
- Public analytics must report current portable EXE clicks; a historical ZIP may use a separate legacy event.
- The canonical 62-page rendered site validator is `scripts/validate-product-build.py`. The obsolete `scripts/validate-built-landing.py` command is only a compatibility forwarding entry point; do not resurrect its stale eight-page, Portable ZIP policy.
- `publish-verified-release.yml` remains a **legacy ZIP-format artifact publisher** with a version gate. It must reject a publication request for v1.6.42+ rather than quietly publish a ZIP under a modern tag. **The canonical modern publish lane remains `release-windows.yml`.**
- `release-supply-chain.yml` backfills *historical extractable ZIP* assets. It must fail closed for opaque single-EXE releases; don't invent an extracted EXE file inventory or replace an existing SBOM. Current v1.6.42 already has an SPDX SBOM and provenance from its canonical release.
- `scripts/validate-product-source.py` verifies exact modern asset name and download URL (not a permissive ZIP-or-EXE choice), while keeping legacy version handling explicit.

## P2B — public screenshot data safety (separate human review)

Eight user-supplied authentic v1.6.42 screenshots and their WebP counterparts are public: IED Browser, Event Logs, Signal Testing, FAT PDF Preview, SMV, GOOSE, Annunciator, Diagnostics.

**Known provenance:** #743 records operator approval to publish the supplied screenshot captures. The existing manifest `docs/asset-provenance-manifest.json` records image paths, Git blob IDs and byte sizes. This is **not** independent confirmation that every visible IP address, station/device label, log identifier, person or customer is harmless.

The Pages source gate `scripts/audit-public-media.py` checks the 16 current PNG/WebP assets for image-container EXIF/XMP/text chunks and malformed headers. Results are uploaded as `media-audit.json`. It reads but never alters source bytes. **This does not inspect rasterized pixels** and does not replace the required visual review.

### Human review acceptance checklist

- [ ] View all eight original **PNG** captures at full resolution and all eight **WebP** derivatives; check cropped/offscreen panes, popovers, titles, tabs and status bar.
- [ ] Assess every visible IP/MAC, VLAN, station/feeder/device label, engineer/customer name, serial/asset ID, filename, SCL/path, event/packet log, credentials/certificates, and topology clue for authorized public disclosure.
- [ ] Record reviewer, review date, each capture's exact SHA/blob reference and disposition in a separate review issue or signed off review document.
- [ ] If any pixel-level disclosure is not authorized, remove or redact **both** public derivatives through a separately reviewed change; update provenance and all references. Do not silently alter the original operator capture under the same SHA or claim it is unchanged.
- [ ] Confirm that screenshots demonstrate product UI only; no certification or universal compatibility claim is inferred from simulated/limited field test evidence.

**Review status:** publication approved by the operator; automated hidden-metadata audit added; **independent pixel/content sanitization NOT YET ASSERTED**. Keep this review open until a person with the authority to disclose the shown engineering data signs off. Do not mark it complete solely because CI is green.

## P4 — sitemap correction

The actual October 25 P1 content refresh modified EN/ID Quick Start, Demo, FAQ, and Compatibility pages. Their sitemap registry entries are stamped with **2026-10-25**. This fixes demonstrably stale `lastmod` dates without falsifying unrelated older pages. Subsequent SEO/mobile/accessibility measurement remains a separate review.

## Next phase gates

1. Complete and document the human screenshot disclosure review.
2. Audit all 62 rendered site pages for keyboard access, links, contrast, mobile breakpoints, and Core Web Vitals using measured evidence.
3. Simplify the beginner-first README and contribution paths without removing historical changelog, trust boundaries or Git provenance.
4. Keep PR #729 source qualification as a **separate** physical R7/R10 workstream. Mainline source promotion does not follow from website CI success.
4 changes: 3 additions & 1 deletion landing/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@

const classifyDownload = href => {
if (href.endsWith('/ARSAS-Windows-x64-Setup.exe')) return ['download_installer', 'ARSAS-Windows-x64-Setup.exe'];
if (href.endsWith('/ARSAS-Windows-x64-Portable.zip')) return ['download_portable', 'ARSAS-Windows-x64-Portable.zip'];
if (href.endsWith('/ARSAS-Windows-x64-Portable.exe')) return ['download_portable', 'ARSAS-Windows-x64-Portable.exe'];
// Historical tagged ZIP links remain classifiable but never masquerade as latest stable.
if (/\/releases\/download\/v\d+\.\d+\.\d+\/ARSAS-Windows-x64-Portable\.zip(?:[?#].*)?$/.test(href)) return ['download_portable_legacy', 'ARSAS-Windows-x64-Portable.zip'];
if (href.endsWith('/ARSAS-Windows-x64-SHA256SUMS.txt')) return ['download_checksums', 'ARSAS-Windows-x64-SHA256SUMS.txt'];
return null;
};
Expand Down
16 changes: 8 additions & 8 deletions landing/site.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
},
"changefreq": "monthly",
"priority": "0.97",
"lastmod": "2026-09-22"
"lastmod": "2026-10-25"
},
{
"path": "panduan-mulai-arsas.html",
Expand All @@ -131,7 +131,7 @@
},
"changefreq": "monthly",
"priority": "0.95",
"lastmod": "2026-09-22"
"lastmod": "2026-10-25"
},
{
"path": "learning-center.html",
Expand Down Expand Up @@ -229,7 +229,7 @@
},
"changefreq": "monthly",
"priority": "0.92",
"lastmod": "2026-07-27"
"lastmod": "2026-10-25"
},
{
"path": "faq-arsas.html",
Expand All @@ -243,7 +243,7 @@
},
"changefreq": "monthly",
"priority": "0.90",
"lastmod": "2026-07-27"
"lastmod": "2026-10-25"
},
{
"path": "compatibility.html",
Expand All @@ -257,7 +257,7 @@
},
"changefreq": "monthly",
"priority": "0.91",
"lastmod": "2026-09-22"
"lastmod": "2026-10-25"
},
{
"path": "bukti-kompatibilitas.html",
Expand All @@ -271,7 +271,7 @@
},
"changefreq": "monthly",
"priority": "0.89",
"lastmod": "2026-09-22"
"lastmod": "2026-10-25"
},
{
"path": "demo.html",
Expand All @@ -285,7 +285,7 @@
},
"changefreq": "monthly",
"priority": "0.90",
"lastmod": "2026-09-22"
"lastmod": "2026-10-25"
},
{
"path": "demo-arsas.html",
Expand All @@ -299,7 +299,7 @@
},
"changefreq": "monthly",
"priority": "0.88",
"lastmod": "2026-09-22"
"lastmod": "2026-10-25"
},
{
"path": "features.html",
Expand Down
111 changes: 111 additions & 0 deletions scripts/audit-public-media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Audit non-pixel metadata in the public v1.6.42 PNG/WebP captures.

This is an automated hidden-metadata boundary, NOT proof that on-screen IP
addresses, device identities or customer information have been sanitized.
"""

from __future__ import annotations

import argparse
import json
import struct
import sys
from pathlib import Path

CAPTURES = (
"ied-browser", "event-logs", "signal-testing", "fat-report-preview",
"smv", "goose", "annunciator", "diagnostics",
)
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
PRIVATE_PNG_CHUNKS = {b"tEXt", b"zTXt", b"iTXt", b"eXIf"}
PRIVATE_WEBP_CHUNKS = {b"EXIF", b"XMP "}


def png_metadata(data: bytes) -> list[str]:
if not data.startswith(PNG_SIGNATURE):
raise ValueError("not PNG")
cursor = len(PNG_SIGNATURE)
found: list[str] = []
terminated = False
while cursor + 12 <= len(data):
length = int.from_bytes(data[cursor:cursor + 4], "big")
kind = data[cursor + 4:cursor + 8]
cursor += 8
if length > len(data) - cursor - 4:
raise ValueError("truncated PNG chunk")
cursor += length + 4 # data + CRC
if kind in PRIVATE_PNG_CHUNKS:
found.append(kind.decode("ascii"))
if kind == b"IEND":
terminated = True
break
if not terminated or cursor != len(data):
raise ValueError("missing/invalid PNG end")
return found


def webp_metadata(data: bytes) -> list[str]:
if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"WEBP":
raise ValueError("not WebP")
length = struct.unpack_from("<I", data, 4)[0] + 8
if length != len(data):
raise ValueError("invalid RIFF length")
cursor = 12
found: list[str] = []
while cursor + 8 <= len(data):
kind = data[cursor:cursor + 4]
size = struct.unpack_from("<I", data, cursor + 4)[0]
cursor += 8
if size > len(data) - cursor:
raise ValueError("truncated WebP chunk")
if kind in PRIVATE_WEBP_CHUNKS:
found.append(kind.decode("ascii").strip())
cursor += size + (size & 1)
if cursor != len(data):
raise ValueError("invalid WebP chunk alignment")
return found


def audit(root: Path) -> list[dict[str, object]]:
screens = root / "landing" / "assets" / "screenshots"
results: list[dict[str, object]] = []
for name in CAPTURES:
for ext, scanner in (("png", png_metadata), ("webp", webp_metadata)):
path = screens / f"arsas-{name}-v1.6.42.{ext}"
try:
data = path.read_bytes()
metadata = scanner(data)
status = "PASS" if not metadata else "FAIL"
finding = ",".join(metadata)
except (OSError, ValueError) as exc:
status = "FAIL"
finding = type(exc).__name__ # never copy potentially private metadata
results.append({
"file": path.name, "status": status, "metadata_chunks": finding
})
return results


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
parser.add_argument("--report", type=Path)
args = parser.parse_args()
rows = audit(args.root)
if args.report:
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps({"scope": "metadata-only", "screens": rows},
indent=2) + "\n", encoding="utf-8")
failures = [row for row in rows if row["status"] != "PASS"]
print(f"Public media embedded-metadata audit: {len(rows)-len(failures)}/{len(rows)} clean source assets.")
for row in failures:
print(f"- {row['file']}: {row['metadata_chunks']}", file=sys.stderr)
if failures:
print("Inspect the original image and seek owner approval before replacing public evidence.", file=sys.stderr)
print("Pixel-level private-network/IED identity review remains a separate human gate.")
return 1 if failures else 0


if __name__ == "__main__":
raise SystemExit(main())
59 changes: 59 additions & 0 deletions scripts/test-audit-public-media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Deterministic positive/negative fixtures for the public screenshot metadata gate."""

from __future__ import annotations

import importlib.util
import struct
import unittest
import zlib
from pathlib import Path

module_path = Path(__file__).with_name("audit-public-media.py")
spec = importlib.util.spec_from_file_location("audit_public_media", module_path)
if spec is None or spec.loader is None:
raise RuntimeError("Unable to import public media audit")
media = importlib.util.module_from_spec(spec)
spec.loader.exec_module(media)


def png_chunk(kind: bytes, payload: bytes) -> bytes:
return (struct.pack(">I", len(payload)) + kind + payload
+ struct.pack(">I", zlib.crc32(kind + payload)))


def webp_chunk(kind: bytes, payload: bytes) -> bytes:
return kind + struct.pack("<I", len(payload)) + payload + (b"\x00" if len(payload) & 1 else b"")


class PublicMediaTests(unittest.TestCase):
def test_clean_png_passes(self) -> None:
png = media.PNG_SIGNATURE + png_chunk(b"IHDR", b"\x00" * 13) + png_chunk(b"IEND", b"")
self.assertEqual(media.png_metadata(png), [])

def test_png_text_metadata_fails(self) -> None:
png = (media.PNG_SIGNATURE + png_chunk(b"IHDR", b"\x00" * 13)
+ png_chunk(b"iTXt", b"private") + png_chunk(b"IEND", b""))
self.assertEqual(media.png_metadata(png), ["iTXt"])

def test_truncated_png_fails(self) -> None:
with self.assertRaises(ValueError):
media.png_metadata(media.PNG_SIGNATURE + b"\x00" * 10)

def test_clean_webp_passes(self) -> None:
payload = webp_chunk(b"VP8L", b"\x00\x00")
webp = b"RIFF" + struct.pack("<I", len(payload) + 4) + b"WEBP" + payload
self.assertEqual(media.webp_metadata(webp), [])

def test_webp_exif_fails(self) -> None:
payload = webp_chunk(b"VP8L", b"\x00\x00") + webp_chunk(b"EXIF", b"private")
webp = b"RIFF" + struct.pack("<I", len(payload) + 4) + b"WEBP" + payload
self.assertEqual(media.webp_metadata(webp), ["EXIF"])

def test_bad_webp_size_fails(self) -> None:
with self.assertRaises(ValueError):
media.webp_metadata(b"RIFF" + struct.pack("<I", 90) + b"WEBP")


if __name__ == "__main__":
unittest.main()
Loading
Loading