@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.
- package/README.md +14 -6
- package/package.json +1 -1
- package/runtime/skills/distribution/generated/recipes.json +220 -25
- package/runtime/skills/distribution/scripts/bundles.mjs +167 -48
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
- package/runtime/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/compliance/compliance-audit/LICENSE +3 -0
- package/skills/compliance/compliance-audit/SKILL.md +466 -0
- package/skills/compliance/compliance-audit/references/findings-schema.md +290 -0
- package/skills/compliance/compliance-audit/references/vars-checklist.md +1068 -0
- package/skills/compliance/compliance-audit/references/vars-findings-schema.md +395 -0
- package/skills/compliance/compliance-audit/scripts/reconcile_tracker.py +454 -0
- package/skills/compliance/compliance-audit/scripts/test_validate_tracker.py +90 -0
- package/skills/compliance/compliance-audit/scripts/validate_tracker.py +752 -0
- package/skills/compliance/doc-authenticity/LICENSE +3 -0
- package/skills/compliance/doc-authenticity/SKILL.md +158 -0
- package/skills/compliance/doc-authenticity/references/agreement.md +66 -0
- package/skills/compliance/doc-authenticity/references/bank-statement.md +150 -0
- package/skills/compliance/doc-authenticity/references/forensic-signals.md +157 -0
- package/skills/compliance/doc-authenticity/references/image-signals.md +114 -0
- package/skills/compliance/doc-authenticity/references/invoice.md +66 -0
- package/skills/compliance/doc-authenticity/references/issuer-profiles.md +86 -0
- package/skills/compliance/doc-authenticity/references/verdict-and-report.md +95 -0
- package/skills/compliance/doc-authenticity/references/verdict.schema.json +81 -0
- package/skills/compliance/doc-authenticity/scripts/image_forensics.py +294 -0
- package/skills/compliance/doc-authenticity/scripts/pdf_forensics.py +401 -0
- package/skills/compliance/doc-authenticity/scripts/validate_codes.py +224 -0
- package/skills/compliance/doc-authenticity/scripts/validate_verdict.py +308 -0
- package/skills/compliance/report-writing/LICENSE +3 -0
- package/skills/compliance/report-writing/SKILL.md +206 -0
- package/skills/compliance/report-writing/references/audit-report-template.md +121 -0
- package/skills/compliance/report-writing/references/external-assessment-template.md +167 -0
- package/skills/compliance/report-writing/references/management-report-template.md +145 -0
- package/skills/compliance/report-writing/references/vars-report-template.md +224 -0
- package/skills/document-utils/doc-to-md/LICENSE +3 -0
- package/skills/document-utils/doc-to-md/SKILL.md +77 -0
- package/skills/document-utils/doc-to-md/scripts/convert.py +157 -0
- package/skills/document-utils/reg-parser/LICENSE +3 -0
- package/skills/document-utils/reg-parser/SKILL.md +201 -0
- package/skills/document-utils/reg-parser/references/checklist-schema.md +223 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.json +1306 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-amlo-checklist.md +469 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.json +634 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-fi-corporate-onboarding-cdd-checklist.md +233 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.json +2194 -0
- package/skills/document-utils/reg-parser/references/checklists/hk-mso-guideline-checklist.md +771 -0
- package/skills/document-utils/reg-parser/references/checklists/kyc-aml-checklist.md +258 -0
- package/skills/document-utils/reg-parser/references/checklists/visa-vars-checklist.md +1058 -0
- package/skills/document-utils/reg-parser/references/mandates/hk-amlo-cap615.md +389 -0
- package/skills/document-utils/reg-parser/references/mandates/visa-vars.md +205 -0
- package/skills/document-utils/reg-parser/scripts/compare_checklist.py +228 -0
- package/skills/document-utils/reg-parser/scripts/dump_checklist.py +348 -0
- package/skills/document-utils/reg-parser/scripts/preprocess-mandate.sh +171 -0
- package/skills/document-utils/reg-parser/scripts/test_quarantine.sh +102 -0
- package/skills/document-utils/reg-parser/scripts/validate_checklist.py +532 -0
- package/skills/document-utils/reg-parser/scripts/validate_mandate.py +242 -0
- package/skills/engineering/engineering-runtime/coherence/workflow.json +16 -16
- package/skills/engineering/engineering-runtime/scripts/workflow-coherence.mjs +4 -0
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/payment/similar-domain/LICENSE +3 -0
- package/skills/payment/similar-domain/SKILL.md +178 -0
- package/skills/payment/similar-domain/references/entities.md +58 -0
- package/skills/payment/similar-domain/references/fingerprints.md +82 -0
- package/skills/payment/similar-domain/references/pivots.md +66 -0
- package/skills/payment/similar-domain/references/report-template.md +76 -0
- package/skills/payment/similar-domain/references/scoring.md +61 -0
- package/skills/payment/similar-domain/references/terminology.md +37 -0
- package/skills/payment/similar-domain/references/workflow.md +79 -0
- package/skills/payment/tld-detection/SKILL.md +76 -21
- package/skills/payment/tld-detection/references/clean.md +1 -1
- package/skills/payment/tld-detection/references/clustering.md +21 -6
- package/skills/payment/tld-detection/references/decision.md +7 -2
- package/skills/payment/tld-detection/references/fx-conversion.md +229 -0
- package/skills/payment/tld-detection/references/report-template.md +63 -27
- package/skills/payment/tld-detection/references/streams.md +90 -0
- package/skills/payment/tld-detection/references/terminology.md +15 -7
- package/skills/payment/tld-detection/references/workflow.md +1 -1
- package/skills/payment/tld-detection/scripts/detect_streams.py +542 -0
- package/skills/engineering/ensure-coverage/evals/evals.json +0 -125
- package/skills/engineering/rush-issues/evals/evals.json +0 -65
- package/skills/engineering/rush-release/evals/evals.json +0 -58
- package/skills/fullstack/shadcn/evals/evals.json +0 -90
- package/skills/payment/fraud-analysis/evals/evals.json +0 -40
- package/skills/payment/tld-detection/evals/evals.json +0 -107
- 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())
|