@1aboveio/skills 0.20.2 → 0.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +14 -6
  2. package/package.json +1 -1
  3. package/runtime/skills/distribution/generated/recipes.json +220 -25
  4. package/runtime/skills/distribution/scripts/bundles.mjs +167 -48
  5. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
  6. package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  7. package/skills/compliance/compliance-audit/LICENSE +3 -0
  8. package/skills/compliance/compliance-audit/SKILL.md +466 -0
  9. package/skills/compliance/compliance-audit/references/findings-schema.md +290 -0
  10. package/skills/compliance/compliance-audit/references/vars-checklist.md +1068 -0
  11. package/skills/compliance/compliance-audit/references/vars-findings-schema.md +395 -0
  12. package/skills/compliance/compliance-audit/scripts/reconcile_tracker.py +454 -0
  13. package/skills/compliance/compliance-audit/scripts/test_validate_tracker.py +90 -0
  14. package/skills/compliance/compliance-audit/scripts/validate_tracker.py +752 -0
  15. package/skills/compliance/doc-authenticity/LICENSE +3 -0
  16. package/skills/compliance/doc-authenticity/SKILL.md +158 -0
  17. package/skills/compliance/doc-authenticity/references/agreement.md +66 -0
  18. package/skills/compliance/doc-authenticity/references/bank-statement.md +150 -0
  19. package/skills/compliance/doc-authenticity/references/forensic-signals.md +157 -0
  20. package/skills/compliance/doc-authenticity/references/image-signals.md +114 -0
  21. package/skills/compliance/doc-authenticity/references/invoice.md +66 -0
  22. package/skills/compliance/doc-authenticity/references/issuer-profiles.md +86 -0
  23. package/skills/compliance/doc-authenticity/references/verdict-and-report.md +95 -0
  24. package/skills/compliance/doc-authenticity/references/verdict.schema.json +81 -0
  25. package/skills/compliance/doc-authenticity/scripts/image_forensics.py +294 -0
  26. package/skills/compliance/doc-authenticity/scripts/pdf_forensics.py +401 -0
  27. package/skills/compliance/doc-authenticity/scripts/validate_codes.py +224 -0
  28. package/skills/compliance/doc-authenticity/scripts/validate_verdict.py +308 -0
  29. package/skills/compliance/report-writing/LICENSE +3 -0
  30. package/skills/compliance/report-writing/SKILL.md +206 -0
  31. package/skills/compliance/report-writing/references/audit-report-template.md +121 -0
  32. package/skills/compliance/report-writing/references/external-assessment-template.md +167 -0
  33. package/skills/compliance/report-writing/references/management-report-template.md +145 -0
  34. package/skills/compliance/report-writing/references/vars-report-template.md +224 -0
  35. package/skills/document-utils/doc-to-md/LICENSE +3 -0
  36. package/skills/document-utils/doc-to-md/SKILL.md +77 -0
  37. package/skills/document-utils/doc-to-md/scripts/convert.py +157 -0
  38. package/skills/document-utils/reg-parser/LICENSE +3 -0
  39. package/skills/document-utils/reg-parser/SKILL.md +201 -0
  40. package/skills/document-utils/reg-parser/references/checklist-schema.md +223 -0
  41. package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.json +1306 -0
  42. package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.md +469 -0
  43. package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.json +634 -0
  44. package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.md +233 -0
  45. package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.json +2194 -0
  46. package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.md +771 -0
  47. package/skills/document-utils/reg-parser/references/checklists/kyc-aml-checklist.md +258 -0
  48. package/skills/document-utils/reg-parser/references/checklists/visa-vars-checklist.md +1058 -0
  49. package/skills/document-utils/reg-parser/references/mandates/hk-amlo-cap615.md +389 -0
  50. package/skills/document-utils/reg-parser/references/mandates/visa-vars.md +205 -0
  51. package/skills/document-utils/reg-parser/scripts/compare_checklist.py +228 -0
  52. package/skills/document-utils/reg-parser/scripts/dump_checklist.py +348 -0
  53. package/skills/document-utils/reg-parser/scripts/preprocess-mandate.sh +171 -0
  54. package/skills/document-utils/reg-parser/scripts/test_quarantine.sh +102 -0
  55. package/skills/document-utils/reg-parser/scripts/validate_checklist.py +532 -0
  56. package/skills/document-utils/reg-parser/scripts/validate_mandate.py +242 -0
  57. package/skills/engineering/engineering-runtime/coherence/workflow.json +16 -16
  58. package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
  59. package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
  60. package/skills/payment/similar-domain/LICENSE +3 -0
  61. package/skills/payment/similar-domain/SKILL.md +178 -0
  62. package/skills/payment/similar-domain/references/entities.md +58 -0
  63. package/skills/payment/similar-domain/references/fingerprints.md +82 -0
  64. package/skills/payment/similar-domain/references/pivots.md +66 -0
  65. package/skills/payment/similar-domain/references/report-template.md +76 -0
  66. package/skills/payment/similar-domain/references/scoring.md +61 -0
  67. package/skills/payment/similar-domain/references/terminology.md +37 -0
  68. package/skills/payment/similar-domain/references/workflow.md +79 -0
  69. package/skills/payment/tld-detection/SKILL.md +76 -21
  70. package/skills/payment/tld-detection/references/clean.md +1 -1
  71. package/skills/payment/tld-detection/references/clustering.md +21 -6
  72. package/skills/payment/tld-detection/references/decision.md +7 -2
  73. package/skills/payment/tld-detection/references/fx-conversion.md +229 -0
  74. package/skills/payment/tld-detection/references/report-template.md +63 -27
  75. package/skills/payment/tld-detection/references/streams.md +90 -0
  76. package/skills/payment/tld-detection/references/terminology.md +15 -7
  77. package/skills/payment/tld-detection/references/workflow.md +1 -1
  78. package/skills/payment/tld-detection/scripts/detect_streams.py +542 -0
  79. package/skills/engineering/ensure-coverage/evals/evals.json +0 -125
  80. package/skills/engineering/rush-issues/evals/evals.json +0 -65
  81. package/skills/engineering/rush-release/evals/evals.json +0 -58
  82. package/skills/fullstack/shadcn/evals/evals.json +0 -90
  83. package/skills/payment/fraud-analysis/evals/evals.json +0 -40
  84. package/skills/payment/tld-detection/evals/evals.json +0 -107
  85. package/skills/payment/tld-detection/tests/test_detect_ladders.py +0 -132
@@ -0,0 +1,66 @@
1
+ # Invoice Validity Checks (N1-N8)
2
+
3
+ Run all checks for a document classified as `invoice`. Use PASS, FLAG, FAIL, or
4
+ NA and quote concrete values. Verify calculations programmatically.
5
+
6
+ ## N1 — Supplier identity
7
+
8
+ Verify the supplier's legal name, registration/tax identifiers, registered
9
+ address, and contact domain against current primary registry or tax-authority
10
+ records. A trading name without a legal entity is FLAG; a mechanically invalid
11
+ or registry-mismatched identifier is FAIL.
12
+
13
+ ## N2 — Customer and transaction parties
14
+
15
+ Confirm the billed customer matches the case, purchase order, contract, and
16
+ delivery counterparty. Name/address variation with a plausible operational
17
+ explanation is FLAG; billing an unrelated entity without support is FAIL.
18
+
19
+ ## N3 — Invoice identity and chronology
20
+
21
+ Check invoice number, issue date, due date, currency, purchase-order/reference
22
+ number, and sequence against available adjacent invoices. Impossible dates,
23
+ duplicate invoice numbers for different transactions, or an invoice predating
24
+ the supplier/customer relationship are FAIL when verified.
25
+
26
+ ## N4 — Line and total arithmetic
27
+
28
+ Programmatically verify every `quantity x unit price`, discount, subtotal,
29
+ shipping/fees, tax base, tax amount, rounding adjustment, and grand total. Quote
30
+ the first mismatch with expected and printed values. A genuine material mismatch
31
+ is a decisive validity FAIL.
32
+
33
+ ## N5 — Tax treatment
34
+
35
+ Validate VAT/GST/sales-tax identifiers, jurisdiction, rate, exemptions,
36
+ reverse-charge wording, and tax-inclusive/exclusive treatment. Use current tax
37
+ authority sources. A wrong or invalid printed tax identifier is FAIL; an absent
38
+ field is NA unless legally required, in which case FLAG or FAIL according to
39
+ materiality and certainty.
40
+
41
+ ## N6 — Payment instructions
42
+
43
+ Check currency, beneficiary name, account identifiers, bank/BIC, payment terms,
44
+ and whether the beneficiary is the supplier or a documented collection agent.
45
+ Verify account changes through an independent channel. A beneficiary unrelated
46
+ to the supplier without documented authority is FAIL. Public bank details alone
47
+ do not verify account ownership.
48
+
49
+ ## N7 — Goods, services, and supporting records
50
+
51
+ Compare descriptions, quantities, service/delivery dates, units, Incoterms,
52
+ purchase order, delivery note, and contract. Vague descriptions or missing
53
+ support are FLAG/NA; direct contradictions with supplied records are FAIL.
54
+
55
+ ## N8 — Completeness and external corroboration
56
+
57
+ Check page count, repeated identifiers, required footer/legal text, issuer
58
+ contact channels, and consistency across invoice, PO, agreement, and delivery
59
+ evidence. Verify material facts with primary sources. Rate NA when no independent
60
+ records are available; do not infer validity from a polished template.
61
+
62
+ ## Decisive validity evidence
63
+
64
+ Material arithmetic failure, invalid tax/account checksum, unrelated beneficiary,
65
+ nonexistent supplier, duplicate invoice identity, or direct contradiction with a
66
+ verified PO/delivery record can independently support `INVALID`.
@@ -0,0 +1,86 @@
1
+ # Issuer Profiles — Typography & Digital-Signature Practice
2
+
3
+ Reference for D4 (template/branding) and F8 (digital signature). Every row is
4
+ graded by evidence quality:
5
+
6
+ - **Verified** — confirmed against a cited source or a known-genuine sample;
7
+ date recorded.
8
+ - **Knowledge-grade** — from model knowledge only. Usable to *raise* suspicion
9
+ (FLAG), never on its own to rate FAIL or to lift a confidence cap. Verify
10
+ before relying on it in a deliverable a reviewer will challenge.
11
+
12
+ Two honesty rules when using this file:
13
+
14
+ 1. **Brand font ≠ statement font.** A bank's marketing typeface (from its
15
+ brand guidelines) is not proof its statement PDFs embed that font —
16
+ statement composition platforms sometimes substitute system fonts (Arial,
17
+ Helvetica) for print streams. A mismatch with the brand font is a FLAG;
18
+ FAIL needs either a verified genuine sample of that bank's statement or an
19
+ absurdity no template explains (e.g. monetary figures in Baskerville).
20
+ 2. **Signature absence is NA by default.** Only rate F8 FLAG for a missing
21
+ signature when this file (or a verified sample) shows the issuer *does*
22
+ sign. Conversely, a *present, valid* signature is always strong evidence.
23
+
24
+ ## Typography by issuer
25
+
26
+ | Issuer | Statement/brand typography | Grade | Source / note |
27
+ |---|---|---|---|
28
+ | HSBC (UK/global) | "Univers Next for HSBC" — custom Univers Next cut; pre-2018 materials used Times-style serif | Verified (brand, 2026-07) | Monotype font distribution ("Univers Next for HSBC W01/W02"), HSBC 2018 rebrand coverage |
29
+ | Barclays | "Barclays Expert Sans" (custom, Monotype 2005) — Headline/eText cuts | Verified (brand, 2026-07) | MyFonts "Barclays Expert Sans eText — custom" |
30
+ | NatWest / RBS group | "RN House Sans" (custom, Monotype ~2014, whole RBS group incl. Ulster) + Jubilat as secondary | Verified (brand, 2026-07) | Fonts In Use; Design Week 2016 rebrand |
31
+ | Lloyds Bank | 2013–2024: "Lloyds Bank Jack" (modified FS Jack, Fontsmith); 2024 refresh: customized GT Ultra (Grilli Type) | Verified (brand, 2026-07) | Fontsmith case study; Grilli Type / Creative Review 2024 |
32
+ | Santander | "Santander Text" / "Santander Headline" (custom) | Knowledge-grade | — |
33
+ | Nationwide | Custom sans ("Nationwide Sans" family) since 2023 rebrand | Knowledge-grade | — |
34
+ | Monzo | Custom sans ("Monzo Sans" family); statements minimal single-family | Knowledge-grade | — |
35
+ | Starling | "Starling Sans"-style custom grotesque | Knowledge-grade | — |
36
+ | Revolut | "Aeonik"-family custom sans | Knowledge-grade | — |
37
+ | Chase (US) | Custom sans ("Dear Chase" era: proprietary); older statements Interstate-like | Knowledge-grade | — |
38
+ | Bank of America | "Connections" custom family | Knowledge-grade | — |
39
+ | Citi | Interstate (long-standing brand face) | Knowledge-grade | — |
40
+ | Wells Fargo | "Wells Fargo Sans" custom family | Knowledge-grade | — |
41
+ | Deutsche Bank | Custom sans family ("DB Sans"-style) | Knowledge-grade | — |
42
+ | Binance | "BinancePlex" (custom IBM Plex derivative); UI/exports sans-serif only | Knowledge-grade | — |
43
+ | Coinbase | "Coinbase Sans" / "Coinbase Display" custom family | Knowledge-grade | — |
44
+
45
+ Universally safe negatives (any issuer): monetary values or account
46
+ identifiers set in bookish serifs (Baskerville, Garamond, Times mixed into a
47
+ sans layout), more than ~3 font families on one statement page, or handwriting
48
+ /display faces anywhere in the ledger. No composition platform emits these.
49
+
50
+ ## Digital-signature practice by issuer
51
+
52
+ | Issuer | Signs customer statement PDFs? | Grade | Source / note |
53
+ |---|---|---|---|
54
+ | UK high-street banks (HSBC, Barclays, Lloyds, NatWest, Santander UK, Nationwide) | No cryptographic signature on retail statement PDFs, as far as observed; treat absence as NA | Knowledge-grade | No bank documentation found asserting signing; do not FLAG absence |
55
+ | N26 | **Does not** sign or stamp — official docs state statements "are fully valid without a signature or stamp" | Verified (2026-07) | N26 support: "How to get a bank statement" / bank documents pages |
56
+ | Revolut (Business) | Account confirmations can be generated "signed and stamped" on request; regular statements not confirmed either way | Verified-partial (2026-07) | Revolut Business help: account confirmation document options |
57
+ | Revolut (Personal) | Unconfirmed | Knowledge-grade | — |
58
+ | Wise | Unconfirmed (community reports of signed statements exist; no official doc found) | Knowledge-grade | Search found no authoritative statement |
59
+ | ING | Wholesale/contract flows use eIDAS QES; retail statement sealing unconfirmed | Verified-partial (2026-07) | ING Wholesale eSignature provision instructions (about inbound signatures, not statements) |
60
+ | EU banks generally | eIDAS "qualified electronic seals" on bank-issued PDFs are increasingly common (esp. DE/NL/PL/Baltics); check the specific bank | Knowledge-grade | When a seal is present, validate byte-range coverage (F8) |
61
+ | Binance | Statements/exports carry no known document signature. "Binance Verify" (binance.com/en/official-verification) validates official *channels* (links, emails, social handles), **not** documents | Verified (2026-07) | Binance Verify FAQ |
62
+ | Coinbase, Kraken, other exchanges | Unconfirmed; assume unsigned | Knowledge-grade | — |
63
+ | Interactive Brokers | Unconfirmed; assume unsigned | Knowledge-grade | — |
64
+
65
+ Practical F8 flow: if `signatures` in forensics.json is non-empty → validate
66
+ coverage and signer, and a valid full-file signature from the issuer lifts the
67
+ `NO_EVIDENCE_OF_MODIFICATION` confidence cap. If empty → NA, unless this table (Verified rows
68
+ only) or a verified local sample says the issuer signs, in which case FLAG.
69
+
70
+ ## Maintaining this file
71
+
72
+ The highest-value upgrade path is **local sample verification**: when a
73
+ known-genuine statement for an issuer passes through a case, record its
74
+ profile and promote the row to Verified:
75
+
76
+ ```bash
77
+ uv run {baseDir}/scripts/pdf_forensics.py genuine-sample.pdf --out /tmp/profile
78
+ # record from forensics.json: metadata.producer/creator, fonts[] families,
79
+ # signatures[] presence, then update the issuer's row + date here
80
+ ```
81
+
82
+ Only profile documents whose provenance is certain (fetched directly from the
83
+ issuer's portal, or bank-confirmed). Date every promotion. When a verified
84
+ profile contradicts a knowledge-grade row, the sample wins — fix the row.
85
+ Brand refreshes happen (see Lloyds 2024): a font mismatch against a stale
86
+ profile is a prompt to re-verify, not an automatic FAIL.
@@ -0,0 +1,95 @@
1
+ # Verdict and Report Contract
2
+
3
+ Apply authenticity and validity independently. A result on one axis does not
4
+ determine the other.
5
+
6
+ ## Check results
7
+
8
+ - `PASS`: concrete evidence supports the check.
9
+ - `FLAG`: an anomaly exists but has a plausible innocent explanation; state it.
10
+ - `FAIL`: concrete evidence of material modification or invalid content.
11
+ - `NA`: evidence is unavailable or the check does not apply; state why.
12
+
13
+ Set `decisive: true` only for a FAIL that independently meets a decisive rule
14
+ in the selected reference. PASS, FLAG, and NA checks are never decisive.
15
+
16
+ ## Authenticity verdict
17
+
18
+ Apply the first matching row:
19
+
20
+ | Verdict | Rule |
21
+ |---|---|
22
+ | `LIKELY_MODIFIED` | At least two authenticity FAILs; one FAIL corroborated by at least two authenticity FLAGs; or one decisive FAIL such as a confirmed material splice, invalid trusted provenance, or a PDF revision that changed visible content |
23
+ | `SUSPICIOUS` | One non-decisive authenticity FAIL; verified image-editor contact (I2 FLAG); or at least three authenticity FLAGs |
24
+ | `INCONCLUSIVE` | No FAIL, no preceding suspicious rule matched, and at least half of authenticity checks are NA |
25
+ | `NO_EVIDENCE_OF_MODIFICATION` | No FAIL, at most two FLAGs, and a majority of authenticity checks ran |
26
+
27
+ `NO_EVIDENCE_OF_MODIFICATION` means no modification was detected; it is not
28
+ proof of origin. In the structured verdict, HIGH confidence requires F8/I9
29
+ PASS; describe source-side or hash-matched verification separately.
30
+
31
+ ## Validity verdict
32
+
33
+ For `conversation_log` and `other`, return `INCONCLUSIVE` because no specialized
34
+ validity profile ran.
35
+
36
+ For `bank_statement`, `invoice`, and `agreement`, apply the first matching row:
37
+
38
+ | Verdict | Rule |
39
+ |---|---|
40
+ | `INVALID` | One decisive validity FAIL or at least two independent validity FAILs |
41
+ | `INCONCLUSIVE` | No decisive FAIL and fewer than two FAILs, but any FAIL remains, more than two checks are FLAG, or at least half of validity checks are NA |
42
+ | `VALID` | No FAIL, at most two FLAGs, a majority of checks ran, and internal plus domain evidence supports the material claim |
43
+
44
+ ## Confidence
45
+
46
+ Assign `HIGH`, `MEDIUM`, or `LOW` separately per axis:
47
+
48
+ - HIGH: at least two independent evidence types agree and few checks are NA.
49
+ - MEDIUM: one strong evidence type or moderate unavailable coverage.
50
+ - LOW: sparse or conflicting evidence; state what would raise confidence.
51
+
52
+ ## Deliverables
53
+
54
+ Write `report.md`:
55
+
56
+ ```markdown
57
+ # Authenticity and Validity Report: <filename>
58
+
59
+ **File type:** pdf | image
60
+ **Document type:** bank_statement | conversation_log | invoice | agreement | other
61
+ **Authenticity:** <token>
62
+ **Authenticity confidence:** <token>
63
+ **Validity:** <token>
64
+ **Validity confidence:** <token>
65
+ **Date checked:** <ISO 8601 timestamp with timezone>
66
+
67
+ ## Summary
68
+ <Both verdicts, decisive evidence, and next action in 2-4 sentences.>
69
+
70
+ ## Classification
71
+ <Observed evidence supporting file type and document type.>
72
+
73
+ ## Authenticity evidence
74
+ | # | Check | Result | Decisive? | Evidence |
75
+ |---|---|---|---|---|
76
+
77
+ ## Validity evidence
78
+ | # | Check | Result | Decisive? | Evidence |
79
+ |---|---|---|---|---|
80
+ <For conversation_log/other: state that no specialized profile ran.>
81
+
82
+ ## External verification
83
+ <For each lookup: primary-source URL, retrieval date, observed fact, and check ID;
84
+ or state that no external verification was available.>
85
+
86
+ ## Limitations
87
+ <Unavailable evidence and what would raise confidence.>
88
+ ```
89
+
90
+ Write `verdict.json` conforming to `verdict.schema.json` version 2.2. Record
91
+ `size_bytes`, lowercase `sha256`, `extension_magic_match`, and the user's
92
+ `supplied_claim` under `artifact`. Its `checks` array contains the exact
93
+ route-specific IDs listed in `SKILL.md` and a `decisive` boolean on every row.
94
+ Use the same summary text and canonical uppercase verdict/result tokens in both
95
+ files, then run `{baseDir}/scripts/validate_verdict.py`.
@@ -0,0 +1,81 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "doc-authenticity/verdict.schema.json",
4
+ "title": "doc-authenticity routed dual-axis verdict",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schema_version", "artifact", "authenticity", "validity",
9
+ "checks", "summary", "checked_at"
10
+ ],
11
+ "properties": {
12
+ "schema_version": {"const": "2.2"},
13
+ "artifact": {
14
+ "type": "object",
15
+ "additionalProperties": false,
16
+ "required": [
17
+ "path", "file_type", "media_type", "size_bytes", "sha256",
18
+ "extension_magic_match", "doc_type", "detail", "claimed_issuer",
19
+ "supplied_claim"
20
+ ],
21
+ "properties": {
22
+ "path": {"type": "string", "minLength": 1},
23
+ "file_type": {"enum": ["pdf", "image"]},
24
+ "media_type": {"type": "string", "minLength": 1},
25
+ "size_bytes": {"type": "integer", "minimum": 1},
26
+ "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
27
+ "extension_magic_match": {"type": "boolean"},
28
+ "doc_type": {"enum": ["bank_statement", "conversation_log", "invoice", "agreement", "other"]},
29
+ "detail": {"type": ["string", "null"]},
30
+ "claimed_issuer": {"type": ["string", "null"]},
31
+ "supplied_claim": {"type": ["string", "null"]}
32
+ }
33
+ },
34
+ "authenticity": {"$ref": "#/$defs/authenticityVerdict"},
35
+ "validity": {"$ref": "#/$defs/validityVerdict"},
36
+ "checks": {
37
+ "type": "array",
38
+ "minItems": 9,
39
+ "items": {"$ref": "#/$defs/check"}
40
+ },
41
+ "summary": {"type": "string", "minLength": 1},
42
+ "checked_at": {"type": "string", "format": "date-time"}
43
+ },
44
+ "$defs": {
45
+ "confidence": {"enum": ["HIGH", "MEDIUM", "LOW"]},
46
+ "authenticityVerdict": {
47
+ "type": "object",
48
+ "additionalProperties": false,
49
+ "required": ["verdict", "confidence", "summary"],
50
+ "properties": {
51
+ "verdict": {"enum": ["NO_EVIDENCE_OF_MODIFICATION", "SUSPICIOUS", "LIKELY_MODIFIED", "INCONCLUSIVE"]},
52
+ "confidence": {"$ref": "#/$defs/confidence"},
53
+ "summary": {"type": "string", "minLength": 1}
54
+ }
55
+ },
56
+ "validityVerdict": {
57
+ "type": "object",
58
+ "additionalProperties": false,
59
+ "required": ["verdict", "confidence", "summary"],
60
+ "properties": {
61
+ "verdict": {"enum": ["VALID", "INVALID", "INCONCLUSIVE"]},
62
+ "confidence": {"$ref": "#/$defs/confidence"},
63
+ "summary": {"type": "string", "minLength": 1}
64
+ }
65
+ },
66
+ "check": {
67
+ "type": "object",
68
+ "additionalProperties": false,
69
+ "required": ["id", "name", "axis", "dimension", "result", "decisive", "evidence"],
70
+ "properties": {
71
+ "id": {"type": "string", "pattern": "^(F[1-9]|I[1-9]|C[1-5]|D[1-5]|N[1-8]|A[1-8])$"},
72
+ "name": {"type": "string", "minLength": 1},
73
+ "axis": {"enum": ["authenticity", "validity"]},
74
+ "dimension": {"enum": ["file_forensics", "content", "domain"]},
75
+ "result": {"enum": ["PASS", "FLAG", "FAIL", "NA"]},
76
+ "decisive": {"type": "boolean"},
77
+ "evidence": {"type": "string", "minLength": 1}
78
+ }
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,294 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["Pillow>=10"]
4
+ # ///
5
+ """Extract metadata, container, and recompression signals from a raster image.
6
+
7
+ Usage:
8
+ uv run image_forensics.py input.jpg --out outdir/
9
+
10
+ Writes image-forensics.json and, for JPEG inputs, ela.png. The output is
11
+ evidence for analyst review, not an automatic authenticity decision.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import hashlib
18
+ import io
19
+ import json
20
+ import re
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from PIL import ExifTags, Image, ImageChops, ImageEnhance, ImageStat
25
+
26
+ EDITOR_PATTERNS = {
27
+ "Adobe Photoshop": rb"(?i)(adobe\s+photoshop|photoshop\s+3\.0|8BIM)",
28
+ "Adobe Lightroom": rb"(?i)(adobe\s+lightroom|lightroom)",
29
+ "GIMP": rb"(?i)(\bgimp\b|gimp-save)",
30
+ "Canva": rb"(?i)\bcanva\b",
31
+ "Affinity": rb"(?i)\baffinity(?:\s+photo)?\b",
32
+ "Pixelmator": rb"(?i)\bpixelmator\b",
33
+ "ImageMagick": rb"(?i)(imagemagick|magick)",
34
+ "Microsoft image tools": rb"(?i)(microsoft.*(?:photo|image)|windows photo editor)",
35
+ }
36
+
37
+ CAPTURE_PATTERNS = {
38
+ "Apple": rb"(?i)apple iphone|apple screenshot",
39
+ "Google": rb"(?i)google pixel",
40
+ "Samsung": rb"(?i)samsung",
41
+ "Canon": rb"(?i)canon",
42
+ "Nikon": rb"(?i)nikon",
43
+ "Sony": rb"(?i)sony",
44
+ "Fujifilm": rb"(?i)fujifilm",
45
+ }
46
+
47
+
48
+ def serializable(value: Any) -> Any:
49
+ if value is None or isinstance(value, (bool, int, float, str)):
50
+ return value
51
+ if isinstance(value, bytes):
52
+ return value[:256].decode("utf-8", "replace")
53
+ if isinstance(value, (list, tuple)):
54
+ return [serializable(v) for v in value]
55
+ if isinstance(value, dict):
56
+ return {str(k): serializable(v) for k, v in value.items()}
57
+ return str(value)
58
+
59
+
60
+ def extract_exif(image: Image.Image) -> dict[str, Any]:
61
+ out: dict[str, Any] = {}
62
+ try:
63
+ for tag, value in image.getexif().items():
64
+ out[ExifTags.TAGS.get(tag, str(tag))] = serializable(value)
65
+ except Exception as exc: # noqa: BLE001
66
+ out["_error"] = str(exc)
67
+ return out
68
+
69
+
70
+ def jpeg_markers(data: bytes) -> dict[str, Any]:
71
+ names = {
72
+ 0xD8: "SOI",
73
+ 0xD9: "EOI",
74
+ 0xDA: "SOS",
75
+ 0xDB: "DQT",
76
+ 0xC0: "SOF0",
77
+ 0xC2: "SOF2",
78
+ 0xC4: "DHT",
79
+ 0xDD: "DRI",
80
+ 0xE0: "APP0",
81
+ 0xE1: "APP1",
82
+ 0xE2: "APP2",
83
+ 0xED: "APP13",
84
+ 0xEE: "APP14",
85
+ 0xFE: "COM",
86
+ }
87
+ markers: list[dict[str, Any]] = []
88
+ if not data.startswith(b"\xff\xd8"):
89
+ return {"valid_soi": False, "markers": markers}
90
+ markers.append({"name": "SOI", "offset": 0, "length": 2})
91
+ pos = 2
92
+ while pos + 1 < len(data):
93
+ if data[pos] != 0xFF:
94
+ pos += 1
95
+ continue
96
+ while pos < len(data) and data[pos] == 0xFF:
97
+ pos += 1
98
+ if pos >= len(data):
99
+ break
100
+ code = data[pos]
101
+ marker_offset = pos - 1
102
+ pos += 1
103
+ if code == 0xD9:
104
+ markers.append({"name": "EOI", "offset": marker_offset, "length": 2})
105
+ break
106
+ if code in range(0xD0, 0xD8) or code == 0x01:
107
+ markers.append(
108
+ {
109
+ "name": names.get(code, f"0xFF{code:02X}"),
110
+ "offset": marker_offset,
111
+ "length": 2,
112
+ }
113
+ )
114
+ continue
115
+ if pos + 2 > len(data):
116
+ break
117
+ segment_length = int.from_bytes(data[pos : pos + 2], "big")
118
+ payload = data[pos + 2 : pos + segment_length]
119
+ entry: dict[str, Any] = {
120
+ "name": names.get(code, f"0xFF{code:02X}"),
121
+ "offset": marker_offset,
122
+ "length": segment_length + 2,
123
+ }
124
+ if code in (0xE0, 0xE1, 0xE2, 0xED, 0xEE, 0xFE):
125
+ entry["identifier"] = (
126
+ payload[:80].split(b"\x00", 1)[0].decode("latin-1", "replace")
127
+ )
128
+ markers.append(entry)
129
+ pos += segment_length
130
+ if code == 0xDA:
131
+ eoi = data.rfind(b"\xff\xd9")
132
+ if eoi >= 0:
133
+ markers.append({"name": "EOI", "offset": eoi, "length": 2})
134
+ break
135
+ eoi_offset = data.rfind(b"\xff\xd9")
136
+ return {
137
+ "valid_soi": True,
138
+ "markers": markers,
139
+ "eoi_offset": eoi_offset if eoi_offset >= 0 else None,
140
+ "bytes_after_eoi": len(data) - eoi_offset - 2 if eoi_offset >= 0 else None,
141
+ "has_exif_app1": b"Exif\x00\x00" in data,
142
+ "has_xmp": b"http://ns.adobe.com/xap/1.0/" in data,
143
+ "has_photoshop_app13": b"Photoshop 3.0" in data or b"8BIM" in data,
144
+ "has_adobe_app14": any(m["name"] == "APP14" for m in markers),
145
+ }
146
+
147
+
148
+ def estimate_jpeg_quality(image: Image.Image) -> int | None:
149
+ source = getattr(image, "quantization", None)
150
+ if not source or 0 not in source:
151
+ return None
152
+ source_luma = source[0]
153
+ probe = Image.new("RGB", (8, 8), "gray")
154
+ best: tuple[int, int] | None = None
155
+ for quality in range(30, 101):
156
+ buf = io.BytesIO()
157
+ probe.save(buf, format="JPEG", quality=quality)
158
+ buf.seek(0)
159
+ with Image.open(buf) as candidate:
160
+ table = candidate.quantization.get(0, [])
161
+ if len(table) != len(source_luma):
162
+ continue
163
+ distance = sum(abs(a - b) for a, b in zip(source_luma, table))
164
+ if best is None or distance < best[0]:
165
+ best = (distance, quality)
166
+ return best[1] if best else None
167
+
168
+
169
+ def recompression_analysis(image: Image.Image, outdir: Path) -> dict[str, Any]:
170
+ quality = estimate_jpeg_quality(image) or 90
171
+ rgb = image.convert("RGB")
172
+ if max(rgb.size) > 5000:
173
+ rgb.thumbnail((5000, 5000))
174
+ buf = io.BytesIO()
175
+ rgb.save(buf, format="JPEG", quality=quality)
176
+ buf.seek(0)
177
+ with Image.open(buf) as recompressed:
178
+ diff = ImageChops.difference(rgb, recompressed.convert("RGB"))
179
+ gray = diff.convert("L")
180
+ global_mean = ImageStat.Stat(gray).mean[0]
181
+ tile_size = max(32, min(rgb.size) // 20)
182
+ tiles: list[dict[str, Any]] = []
183
+ for top in range(0, rgb.height, tile_size):
184
+ for left in range(0, rgb.width, tile_size):
185
+ box = (
186
+ left,
187
+ top,
188
+ min(left + tile_size, rgb.width),
189
+ min(top + tile_size, rgb.height),
190
+ )
191
+ mean = ImageStat.Stat(gray.crop(box)).mean[0]
192
+ tiles.append({"box": list(box), "mean_error": round(mean, 4)})
193
+ values = sorted(t["mean_error"] for t in tiles)
194
+ p95 = values[min(len(values) - 1, int(len(values) * 0.95))] if values else 0
195
+ max_tile = max(tiles, key=lambda t: t["mean_error"], default=None)
196
+ amplified = ImageEnhance.Contrast(diff).enhance(8)
197
+ amplified = ImageEnhance.Brightness(amplified).enhance(8)
198
+ amplified.save(outdir / "ela.png")
199
+ return {
200
+ "method": "same-quality JPEG recompression difference",
201
+ "estimated_quality": quality,
202
+ "analysis_size": list(rgb.size),
203
+ "tile_size": tile_size,
204
+ "global_mean_error": round(global_mean, 4),
205
+ "p95_tile_error": p95,
206
+ "max_tile": max_tile,
207
+ "highest_error_tiles": sorted(
208
+ tiles, key=lambda t: t["mean_error"], reverse=True
209
+ )[:10],
210
+ "ela_path": str(outdir / "ela.png"),
211
+ "caution": "ELA is heuristic; naturally detailed regions differ from flat regions, and a full-image re-export can hide local edits.",
212
+ }
213
+
214
+
215
+ def main() -> int:
216
+ parser = argparse.ArgumentParser()
217
+ parser.add_argument("input", type=Path)
218
+ parser.add_argument("--out", required=True, type=Path)
219
+ args = parser.parse_args()
220
+ args.out.mkdir(parents=True, exist_ok=True)
221
+
222
+ data = args.input.read_bytes()
223
+ with Image.open(args.input) as image:
224
+ image.verify()
225
+ with Image.open(args.input) as image:
226
+ exif = extract_exif(image)
227
+ info = {
228
+ str(k): serializable(v)
229
+ for k, v in image.info.items()
230
+ if k not in {"exif", "icc_profile"}
231
+ }
232
+ text_blob = (
233
+ data + json.dumps({"exif": exif, "info": info}, ensure_ascii=False).encode()
234
+ )
235
+ editor_matches = [
236
+ name
237
+ for name, pattern in EDITOR_PATTERNS.items()
238
+ if re.search(pattern, text_blob)
239
+ ]
240
+ capture_matches = [
241
+ name
242
+ for name, pattern in CAPTURE_PATTERNS.items()
243
+ if re.search(pattern, text_blob)
244
+ ]
245
+ result: dict[str, Any] = {
246
+ "input": str(args.input.resolve()),
247
+ "file_size": len(data),
248
+ "size_bytes": len(data),
249
+ "sha256": hashlib.sha256(data).hexdigest(),
250
+ "media_type": Image.MIME.get(image.format, "application/octet-stream"),
251
+ "extension_magic_match": args.input.suffix.lower()
252
+ in {
253
+ suffix.lower()
254
+ for suffix, detected_format in Image.registered_extensions().items()
255
+ if detected_format == image.format
256
+ },
257
+ "format": image.format,
258
+ "mode": image.mode,
259
+ "width": image.width,
260
+ "height": image.height,
261
+ "frames": getattr(image, "n_frames", 1),
262
+ "exif": exif,
263
+ "image_info": info,
264
+ "editor_matches": editor_matches,
265
+ "capture_device_matches": capture_matches,
266
+ "provenance": {
267
+ "marker_scan_only": True,
268
+ "cryptographic_validation": "not_performed",
269
+ "has_c2pa_or_jumbf_marker": any(
270
+ token in data.lower()
271
+ for token in (b"c2pa", b"jumb", b"contentauth")
272
+ ),
273
+ "has_content_credentials_text": b"content credentials" in data.lower(),
274
+ },
275
+ }
276
+ if image.format == "JPEG":
277
+ result["jpeg"] = jpeg_markers(data)
278
+ result["recompression"] = recompression_analysis(image, args.out)
279
+ else:
280
+ result["recompression"] = {
281
+ "available": False,
282
+ "reason": "JPEG ELA is not applicable to this source format; inspect metadata and pixels directly.",
283
+ }
284
+
285
+ output = args.out / "image-forensics.json"
286
+ output.write_text(
287
+ json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
288
+ )
289
+ print(output)
290
+ return 0
291
+
292
+
293
+ if __name__ == "__main__":
294
+ raise SystemExit(main())