@runeditrun/rer 0.2.2 → 0.2.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 (86) hide show
  1. package/README.md +9 -2
  2. package/cli/README.md +21 -0
  3. package/cli/container.mjs +11 -10
  4. package/cli/contract.mjs +2 -2
  5. package/cli/demo-host.mjs +47 -0
  6. package/cli/demo.mjs +47 -0
  7. package/cli/dependencies.mjs +26 -4
  8. package/cli/deploy-command.mjs +94 -0
  9. package/cli/deploy-inputs.mjs +22 -0
  10. package/cli/deploy-observation.mjs +28 -0
  11. package/cli/deploy-owner.mjs +52 -37
  12. package/cli/deploy-readiness.mjs +16 -0
  13. package/cli/deploy.mjs +20 -10
  14. package/cli/detached-server.mjs +1 -1
  15. package/cli/export.mjs +1 -1
  16. package/cli/hosted-output.mjs +2 -1
  17. package/cli/lifecycle.mjs +20 -7
  18. package/cli/local.mjs +7 -0
  19. package/cli/main.mjs +1 -1
  20. package/cli/managed-server.mjs +27 -18
  21. package/cli/parse.mjs +7 -2
  22. package/cli/pnpm-probe.mjs +4 -1
  23. package/cli/portability.mjs +4 -1
  24. package/cli/scheduled.mjs +2 -2
  25. package/cli/setup-input.mjs +1 -1
  26. package/global/CHANGELOG.md +46 -0
  27. package/global/README.md +9 -2
  28. package/global/launch/capacity.mjs +1 -1
  29. package/global/launch/facts.mjs +12 -6
  30. package/global/launch/install-pinned.mjs +26 -2
  31. package/global/launch/prepare.mjs +20 -8
  32. package/global/launch/state.mjs +54 -1
  33. package/global/main.mjs +18 -20
  34. package/global/update/command.mjs +2 -1
  35. package/modules/container/build.mjs +4 -2
  36. package/modules/container/host.mjs +1 -1
  37. package/modules/container/ingress.mjs +4 -1
  38. package/modules/container/migrations.mjs +2 -1
  39. package/modules/container/worker-engine.mjs +3 -1
  40. package/modules/demo-host/README.md +30 -0
  41. package/modules/demo-host/capacity.mjs +19 -0
  42. package/modules/demo-host/claim.mjs +21 -0
  43. package/modules/demo-host/container.mjs +74 -0
  44. package/modules/demo-host/pages.mjs +56 -0
  45. package/modules/demo-host/plan.mjs +52 -0
  46. package/modules/demo-host/worker.mjs +72 -0
  47. package/modules/deploy-kit/README.md +27 -0
  48. package/modules/deploy-kit/env-file.mjs +2 -1
  49. package/modules/deploy-kit/health-request.mjs +7 -2
  50. package/modules/deploy-kit/migration-ledger.mjs +38 -1
  51. package/modules/deploy-kit/migrations.mjs +38 -2
  52. package/modules/deploy-kit/owner-cli.mjs +30 -9
  53. package/modules/deploy-kit/owner-remove.mjs +5 -2
  54. package/modules/deploy-kit/owner-secret.mjs +67 -22
  55. package/modules/deploy-kit/owner.mjs +71 -24
  56. package/modules/deploy-kit/prepare.mjs +3 -1
  57. package/modules/deploy-kit/provider.mjs +3 -0
  58. package/modules/erasure/README.md +2 -3
  59. package/modules/operator-auth/accounts/settings-page.mjs +8 -3
  60. package/modules/operator-auth/csp.mjs +26 -0
  61. package/modules/operator-auth/dev/badge.mjs +1 -22
  62. package/modules/operator-auth/page.mjs +2 -2
  63. package/modules/operator-auth/routes.mjs +1 -1
  64. package/modules/portability/README.md +9 -0
  65. package/modules/portability/bridge.mjs +4 -1
  66. package/modules/portability/catalogue.mjs +9 -2
  67. package/modules/portability/index.d.mts +4 -0
  68. package/modules/portability/index.mjs +1 -0
  69. package/modules/portability/lines.mjs +3 -1
  70. package/modules/release/produce.mjs +1 -1
  71. package/modules/release/source.mjs +5 -2
  72. package/onboarding/adapter-labels.mjs +1 -0
  73. package/onboarding/adapter.mjs +9 -4
  74. package/onboarding/dependency-environment.mjs +33 -0
  75. package/onboarding/local/owner.mjs +1 -1
  76. package/onboarding/operator-installer.mjs +10 -4
  77. package/onboarding/redaction.mjs +22 -3
  78. package/package.json +6 -1
  79. package/scripts/publish-check/README.md +22 -1
  80. package/scripts/publish-check/c2pa.py +121 -0
  81. package/scripts/publish-check/content_identities.py +3 -1
  82. package/scripts/publish-check/controller_metadata.py +31 -0
  83. package/scripts/publish-check/history_scan.py +13 -3
  84. package/scripts/publish-check/media.py +283 -0
  85. package/scripts/publish-check/png_text.py +34 -0
  86. package/scripts/publish-check/public_hygiene.py +39 -4
@@ -19,13 +19,16 @@ export function operatorInstaller({ database, features, prepare, prepareData })
19
19
  values: ({ origin }) => ({ AUTH_LOCAL: 'true', AUTH_ORIGIN: origin, MAIL_PROVIDER: 'development', MAIL_FROM: 'auth@localhost.test' }),
20
20
  },
21
21
  build: ['pnpm', ['run', 'build:app']],
22
- migrate: ['pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', database, '--local', '--persist-to', '.wrangler/state', '--config', 'dist/server/wrangler.json']],
22
+ // A demo migrates its own fresh state folder, never the checkout's.
23
+ migrate: ({ demo }) => ['pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', database, '--local', '--persist-to', demo?.state ?? '.wrangler/state', '--config', 'dist/server/wrangler.json']],
23
24
  // Setup uses preview's production runtime behind the same public proxy.
24
25
  start: previewTarget,
25
26
  owner: { existingOwnerViaSignIn: true, displayName: input => input.displayName || 'Owner' },
26
27
  signIn: { emailKey: 'OWNER_EMAIL', returnTo: '/setup' },
27
28
  });
28
- return {...lifecycle,localPrerequisites:async({root,vars,email})=>vars.AUTH_LOCAL==='true'&&vars.MAIL_PROVIDER==='development'&&Boolean(vars.SESSION_SECRET)&&await operatorPrerequisites(root,email),install:(root,input,store,onApp,signal)=>installOperator(root,input,store,onApp,signal,lifecycle.hooks)};
29
+ // A demo migrates and claims the owner in its own state, so it never opens the
30
+ // checkout's database: even read-only, SQLite rewrites its -shm index.
31
+ return {...lifecycle,localPrerequisites:async({root,vars,email,demo})=>vars.AUTH_LOCAL==='true'&&vars.MAIL_PROVIDER==='development'&&Boolean(vars.SESSION_SECRET)&&(demo||await operatorPrerequisites(root,email)),install:(root,input,store,onApp,signal)=>installOperator(root,input,store,onApp,signal,lifecycle.hooks)};
29
32
  }
30
33
 
31
34
  // Only local runtimes receive the dev key; vite.config.ts passes it to `vite dev` alone.
@@ -33,7 +36,10 @@ export function operatorInstaller({ database, features, prepare, prepareData })
33
36
  // without the key. trafficProxy also refuses /cdn-cgi/* on the public listener.
34
37
  const devBindings = (devKey, devLogin) => devKey ? { RER_DEV_KEY: devKey, RER_DEV_IDENTITY: devLogin ? 'off' : 'on' } : {};
35
38
  const internalPort = port => { if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('A local runtime requires the traffic proxy internal port.'); return String(port); };
36
- const developmentEnvironment = () => { const { RER_MODELS_SERVICE, RER_MODELS_KEY, ...env } = process.env; return env; };
37
- export const developmentTarget = ({ root, origin, runtimePort, devKey, devLogin, modelsLive }) => [process.execPath, [projectViteBinary(root), 'dev', '--host', '127.0.0.1', '--port', internalPort(runtimePort), '--strictPort'], { env: { ...developmentEnvironment(), PATH: join(root, 'node_modules', '.bin') + delimiter + (process.env.PATH ?? ''), RER_DEV_ORIGIN: origin, WRANGLER_SEND_METRICS: 'false', X_LOCAL_EXPLORER: 'false', ...devBindings(devKey, devLogin), ...(modelsLive ? { RER_MODELS_SERVICE: modelsLive.baseUrl, RER_MODELS_KEY: modelsLive.apiKey } : {}) } }];
39
+ const developmentEnvironment = () => { const { RER_MODELS_SERVICE, RER_MODELS_KEY, RER_DEMO, RER_DEMO_EXPIRES, RER_STATE_DIR, ...env } = process.env; return env; };
40
+ // A demo's Vite persists to its own folder (the seed's vite.config reads RER_STATE_DIR)
41
+ // and its Worker sees RER_DEMO=1 and the demo's end (modules/demo).
42
+ const demoBindings = demo => demo ? { RER_DEMO: '1', RER_DEMO_EXPIRES: demo.ends, RER_STATE_DIR: demo.state } : {};
43
+ export const developmentTarget = ({ root, origin, runtimePort, devKey, devLogin, modelsLive, demo }) => [process.execPath, [projectViteBinary(root), 'dev', '--host', '127.0.0.1', '--port', internalPort(runtimePort), '--strictPort'], { env: { ...developmentEnvironment(), PATH: join(root, 'node_modules', '.bin') + delimiter + (process.env.PATH ?? ''), RER_DEV_ORIGIN: origin, WRANGLER_SEND_METRICS: 'false', X_LOCAL_EXPLORER: 'false', ...devBindings(devKey, devLogin), ...(modelsLive ? { RER_MODELS_SERVICE: modelsLive.baseUrl, RER_MODELS_KEY: modelsLive.apiKey } : {}), ...demoBindings(demo) } }];
38
44
 
39
45
  export const previewTarget = ({ origin, runtimePort, devKey, scheduled }) => [process.execPath, [fileURLToPath(new URL('./production-runtime.mjs', import.meta.url))], { env: { ...process.env, RER_DEV_ORIGIN: origin, RER_RUNTIME_HOST: '127.0.0.1', RER_RUNTIME_PORT: internalPort(runtimePort), WRANGLER_SEND_METRICS: 'false', CLOUDFLARE_INCLUDE_PROCESS_ENV: 'false', X_LOCAL_EXPLORER: 'false', RER_RUNTIME_TRIGGERS: scheduled ? '1' : '0', ...(devKey ? { RER_DEV_KEY: devKey } : {}) } }];
@@ -35,7 +35,9 @@ const DOUBLE_QUOTED = /"(?:\\[^\r\n]|[^"\\\r\n])*(?:\\(?=[\r\n]|$))?/.source;
35
35
  const SINGLE_QUOTED = /'(?:\\[^\r\n]|[^'\\\r\n])*(?:\\(?=[\r\n]|$))?/.source;
36
36
  const PRIVATE_VALUE = `(?:${DOUBLE_QUOTED}(?:"|(?=[\\r\\n]|$))|${SINGLE_QUOTED}(?:'|(?=[\\r\\n]|$))|[^\\s"'<>;&]+)`;
37
37
  const FIELD_VALUE = '["\']?\\s*[=:]\\s*' + NOT_FLAG + PRIVATE_VALUE;
38
- const CLI_PREFIX = '(?<![A-Za-z0-9_-])--(?:[A-Za-z0-9]+[_-])*';
38
+ // These deploy flags take a binding name, never the secret value. Explicitly
39
+ // registered private bytes remain redacted by the exact-value pass.
40
+ const CLI_PREFIX = '(?<![A-Za-z0-9_-])--(?!(?:set|unset)-secret(?:\\s|$))(?:[A-Za-z0-9]+[_-])*';
39
41
  const CLI_ARGUMENT = CLI_PREFIX + '(' + FIELD_NAME + ')\\s+' + NOT_FLAG + '(' + PRIVATE_VALUE + ')';
40
42
  // A bare Bearer/Basic value must look like a credential, not the next word of
41
43
  // prose such as "Basic checks passed"; after Authorization any value is private.
@@ -57,9 +59,12 @@ const COOKIE_HEADER = '(?<![A-Za-z0-9_-])(?:Set-)?Cookie["\']?\\s*:\\s*(?:[^\\s=
57
59
  const SCHEME = '(?<![a-z0-9+.-])[a-z][a-z0-9+.-]*://';
58
60
  const USERINFO = SCHEME + '([^\\s/?#"<>`]*)@';
59
61
  const SLASHED_USERINFO = SCHEME + '([^\\s/?#:@"<>`\\[\\]]*:(?![0-9]*(?:[/?#\\s"<>`]|$))(?=(?<password>[^\\s"<>`@]{0,128}))\\k<password>)@';
62
+ // Provider token formats recognisable by their prefix alone.
63
+ const TOKEN_PREFIX = '(?:gh[pousr]_|github_pat_|sk-(?:proj-)?|sk_live_|sk_test_|rk_live_|rk_test_|pk_live_|whsec_|SG\\.|xox[baprs]-|AKIA|ASIA|AIza|npm_|glpat-|pypi-|hf_|re_)';
64
+ const KNOWN_TOKEN = '(?<![A-Za-z0-9_])' + TOKEN_PREFIX + '[A-Za-z0-9_./+=-]+';
60
65
  const PATTERNS = new RegExp('(?:' + [
61
66
  AUTH_HEADER, BARE_AUTH,
62
- '(?<![A-Za-z0-9_])(?:gh[pousr]_|github_pat_|sk-(?:proj-)?|sk_live_|sk_test_|rk_live_|rk_test_|pk_live_|whsec_|SG\\.|xox[baprs]-|AKIA|ASIA|AIza|npm_|glpat-|pypi-|hf_|re_)[A-Za-z0-9_./+=-]+',
67
+ KNOWN_TOKEN,
63
68
  FIELD + FIELD_VALUE, COOKIE_HEADER,
64
69
  USERINFO, SLASHED_USERINFO,
65
70
  ].join('|') + ')', 'gi');
@@ -287,12 +292,26 @@ export function createRedaction({mask = MASK} = {}) {
287
292
  return {addSecrets,redact,stream};
288
293
  }
289
294
 
295
+ // A variable whose name marks a credential, or whose value is a URL carrying one.
296
+ export const credentialName = key => CREDENTIAL_NAME.test(key) || CAMEL_CREDENTIAL_NAME.test(key);
297
+ export const credentialUrl = value => CREDENTIAL_URL.test(value);
298
+ // A value that carries a credential whatever its variable is called: a known
299
+ // provider token of at least 16 characters, a JWT, or a URL with userinfo or a
300
+ // credential query or connection-string parameter (libsql://db?authToken=...,
301
+ // ...;AccountKey=...), a private key, or an Authorization header value.
302
+ // A token never continues a path or word (/opt/hf_models_cache is a path).
303
+ const TOKEN_VALUE = new RegExp('(?<![A-Za-z0-9_./-])' + TOKEN_PREFIX + '[A-Za-z0-9_./+=-]{12,}');
304
+ const CREDENTIAL_QUERY = /[?&;](?:auth[_-]?token|access[_-]?token|api[_-]?key|account[_-]?key|shared[_-]?access[_-]?key|token|key|secret|password|sig|signature)=[^&#\s]/i;
305
+ const PRIVATE_KEY_VALUE = /-----BEGIN [A-Z0-9 ]{0,64}PRIVATE KEY/;
306
+ const AUTHORIZATION_VALUE = new RegExp(AUTH_HEADER + '|' + BARE_AUTH, 'i');
307
+ export const credentialValue = value => TOKEN_VALUE.test(value) || jwtRanges(value).length > 0 || CREDENTIAL_URL.test(value) || CREDENTIAL_QUERY.test(value) || PRIVATE_KEY_VALUE.test(value) || AUTHORIZATION_VALUE.test(value);
308
+
290
309
  export function credentialValues(input, declared = []) {
291
310
  if (!input || typeof input !== 'object') return [];
292
311
  return Object.entries(input).flatMap(([key,value]) => {
293
312
  // An explicit model declaration remains an exact secret contract, including
294
313
  // short values. Environment heuristics must not turn boolean flags into masks.
295
- if (typeof value === 'string') return declared.includes(key) || (value.length>=8 && (CREDENTIAL_NAME.test(key) || CAMEL_CREDENTIAL_NAME.test(key) || CREDENTIAL_URL.test(value))) ? [value] : [];
314
+ if (typeof value === 'string') return declared.includes(key) || (value.length>=8 && (credentialName(key) || credentialUrl(value))) ? [value] : [];
296
315
  return value && typeof value==='object' ? credentialValues(value,declared) : [];
297
316
  });
298
317
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runeditrun/rer",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "The rer command: install, set up, run and launch Run Edit Run checkouts.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -63,6 +63,7 @@
63
63
  "./modules/portability/migration-history": "./modules/portability/migration-history.mjs",
64
64
  "./modules/portability/storage": "./modules/portability/storage.mjs",
65
65
  "./modules/erasure/fence": "./modules/erasure/fence.mjs",
66
+ "./modules/deploy-kit/migration-ledger": "./modules/deploy-kit/migration-ledger.mjs",
66
67
  "./modules/deploy-kit/owner": "./modules/deploy-kit/owner.mjs",
67
68
  "./modules/deploy-kit/owner-cli": "./modules/deploy-kit/owner-cli.mjs",
68
69
  "./modules/deploy-kit/prepare": "./modules/deploy-kit/prepare.mjs",
@@ -84,5 +85,9 @@
84
85
  },
85
86
  "engines": {
86
87
  "node": ">=22"
88
+ },
89
+ "repository": {
90
+ "type": "git",
91
+ "url": "git+https://github.com/runeditrun/base.git"
87
92
  }
88
93
  }
@@ -21,7 +21,20 @@ bounded public coauthor address rule described in the command guide; a file name
21
21
  masks only the accepted address for email inspection, leaving names, other
22
22
  addresses and every other rule intact.
23
23
 
24
- Binary metadata decoding, Git protocol/object/ref modelling, frozen comparison,
24
+ `png_text.py` separates the fields of framed PNG `tEXt` and uncompressed `iTXt`
25
+ chunks before text hygiene inspection. Container length, type and CRC bytes
26
+ cannot extend a mailbox, and these explicit text fields do not use the binary
27
+ mixed-case host heuristic. Raw-byte hygiene inspection retains the original
28
+ binary classification outside those chunks; checksums do not suppress text
29
+ inspection. Gitleaks still receives the original bytes. Compressed metadata is
30
+ not decoded. `media.py` walks image and video containers to find their
31
+ pixel and sample spans; only those spans skip text hygiene, never
32
+ environment or Gitleaks inspection, and metadata around them is still text.
33
+ `c2pa.py` finds the strictly parsed X.509 certificates inside a PNG's C2PA
34
+ manifest store (`caBX`) and returns their Name and rfc822Name value spans,
35
+ which skip text hygiene the same way.
36
+
37
+ Other binary metadata decoding, Git protocol/object/ref modelling, frozen comparison,
25
38
  resumable inspection and their tests have been removed. Tests live in `tests.py`
26
39
  and `coverage_tests.py` and construct disposable repositories with actual Git.
27
40
 
@@ -80,3 +93,11 @@ The build that produces the artifacts (`modules/egress-service/tls/build.mjs`)
80
93
  regenerates the manifest, and its `--check` mode rejects a stale one. An
81
94
  alphabetic top-level domain label ends before a following digit, so a match
82
95
  ends at the DER string's boundary rather than absorbing the next tag byte.
96
+
97
+ `controller_metadata.py` recognizes the standalone controller review manifest by
98
+ its exact four-field structure, immutable source revision, descriptor digest,
99
+ relative directory list and path-to-SHA-256 file map, at any JSON path. Only
100
+ those digest value locations qualify. Extra fields, malformed paths or digests
101
+ retain findings. The live-proof controller still independently checks all copied
102
+ file bytes against the pinned source before execution; the scanner does not claim
103
+ to fetch that source. Provider tokens and known environment values remain findings.
@@ -0,0 +1,121 @@
1
+ """Certificate metadata inside a PNG's C2PA manifest store (caBX chunk).
2
+
3
+ Image generators sign a C2PA manifest and embed the signing certificate chain
4
+ in it; a certificate names its holder, often by address. Only values that a
5
+ strict X.509 parse places in an issuer or subject Name, or in a subject or
6
+ issuer alternative name's rfc822Name, are certificate metadata. Every other
7
+ byte of the manifest, assertions included, remains text to inspect.
8
+ """
9
+ from der import OID, SEQUENCE, BIT_STRING, rdn_values, tlv
10
+
11
+ INTEGER, OCTET_STRING, VERSION, EXTENSIONS, RFC822_NAME = 0x02, 0x04, 0xA0, 0xA3, 0x81
12
+ ALTERNATIVE_NAMES = (bytes.fromhex('551d11'), bytes.fromhex('551d12'))
13
+
14
+
15
+ def manifest_payloads(data):
16
+ if not data.startswith(b'\x89PNG\r\n\x1a\n'):
17
+ return
18
+ offset = 8
19
+ while offset + 12 <= len(data):
20
+ length = int.from_bytes(data[offset:offset + 4], 'big')
21
+ kind, start, end = data[offset + 4:offset + 8], offset + 8, offset + 8 + length
22
+ if end + 4 > len(data):
23
+ return
24
+ if kind == b'caBX':
25
+ yield start, end
26
+ if kind == b'IEND':
27
+ return
28
+ offset = end + 4
29
+
30
+
31
+ def name_values(data, name):
32
+ # A Name is RDN SETs filling its SEQUENCE exactly.
33
+ values, cursor = [], name[1]
34
+ while cursor < name[2]:
35
+ rdn = rdn_values(data, cursor, name[2])
36
+ if not rdn:
37
+ return None
38
+ cursor, found = rdn
39
+ values += found
40
+ return values
41
+
42
+
43
+ def alternative_names(data, extensions):
44
+ spans, outer = [], tlv(data, extensions[1], extensions[2])
45
+ if not outer or outer[0] != SEQUENCE or outer[2] != extensions[2]:
46
+ return None
47
+ cursor = outer[1]
48
+ while cursor < outer[2]:
49
+ extension = tlv(data, cursor, outer[2])
50
+ oid = extension and extension[0] == SEQUENCE and tlv(data, extension[1], extension[2])
51
+ if not oid or oid[0] != OID:
52
+ return None
53
+ value = tlv(data, oid[2], extension[2])
54
+ if value and value[0] == 0x01:
55
+ value = tlv(data, value[2], extension[2])
56
+ if not value or value[0] != OCTET_STRING or value[2] != extension[2]:
57
+ return None
58
+ if data[oid[1]:oid[2]] in ALTERNATIVE_NAMES:
59
+ general = tlv(data, value[1], value[2])
60
+ if not general or general[0] != SEQUENCE:
61
+ return None
62
+ entry = general[1]
63
+ while entry < general[2]:
64
+ item = tlv(data, entry, general[2])
65
+ if not item:
66
+ return None
67
+ if item[0] == RFC822_NAME:
68
+ spans.append((item[1], item[2]))
69
+ entry = item[2]
70
+ cursor = extension[2]
71
+ return spans
72
+
73
+
74
+ def certificate(data, offset, end):
75
+ """Name and alternative-name value spans of one X.509 Certificate, or None."""
76
+ outer = tlv(data, offset, end)
77
+ if not outer or outer[0] != SEQUENCE:
78
+ return None
79
+ tbs = tlv(data, outer[1], outer[2])
80
+ algorithm = tbs and tbs[0] == SEQUENCE and tlv(data, tbs[2], outer[2])
81
+ signature = algorithm and algorithm[0] == SEQUENCE and tlv(data, algorithm[2], outer[2])
82
+ if not signature or signature[0] != BIT_STRING or signature[2] != outer[2]:
83
+ return None
84
+ fields, cursor = [], tbs[1]
85
+ while cursor < tbs[2]:
86
+ field = tlv(data, cursor, tbs[2])
87
+ if not field:
88
+ return None
89
+ fields.append(field)
90
+ cursor = field[2]
91
+ if fields and fields[0][0] == VERSION:
92
+ fields = fields[1:]
93
+ # serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
94
+ if len(fields) < 6 or [field[0] for field in fields[:6]] != [INTEGER] + [SEQUENCE] * 5:
95
+ return None
96
+ issuer, subject = name_values(data, fields[2]), name_values(data, fields[4])
97
+ if issuer is None or subject is None:
98
+ return None
99
+ spans = issuer + subject
100
+ for field in fields[6:]:
101
+ if field[0] == EXTENSIONS:
102
+ names = alternative_names(data, field)
103
+ if names is None:
104
+ return None
105
+ spans += names
106
+ return spans
107
+
108
+
109
+ def certificate_spans(data):
110
+ """Value spans of the certificates a PNG's C2PA manifest store carries."""
111
+ spans = []
112
+ for start, end in manifest_payloads(data):
113
+ offset = data.find(SEQUENCE.to_bytes(1, 'big'), start, end)
114
+ while 0 <= offset < end:
115
+ found = certificate(data, offset, end)
116
+ if found is None:
117
+ offset = data.find(SEQUENCE.to_bytes(1, 'big'), offset + 1, end)
118
+ continue
119
+ spans += found
120
+ offset = data.find(SEQUENCE.to_bytes(1, 'big'), tlv(data, offset, end)[2], end)
121
+ return spans
@@ -2,11 +2,12 @@
2
2
  from json_identity import Document
3
3
  from provenance_pins import module_pins
4
4
  from copy_identities import copy_pointers
5
+ from controller_metadata import controller_pointers
5
6
  from source_metadata import ARCHIVE, source_pointers
6
7
 
7
8
 
8
9
  def candidate(path):
9
- return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path))
10
+ return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path)) or path.endswith('.json')
10
11
 
11
12
 
12
13
  class ContentIdentities:
@@ -22,6 +23,7 @@ class ContentIdentities:
22
23
  self.documents[path] = document
23
24
  pointers = set(module_pins(path, document.value))
24
25
  pointers.update(source_pointers(path, document.value))
26
+ pointers.update(controller_pointers(document.value))
25
27
  if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
26
28
  pointers.update(copy_pointers(document.value, read_regular))
27
29
  self.allowed[path] = pointers
@@ -0,0 +1,31 @@
1
+ """Exact digest locations in immutable standalone-controller review manifests.
2
+
3
+ The live-proof controller verifies these digests against its pinned Git source
4
+ before executing any copied file. This identifies the documented format; it does
5
+ not claim to perform that remote integrity check during publication inspection.
6
+ """
7
+ from copy_identities import relative_path
8
+ from json_identity import HEX, GIT_REVISION
9
+
10
+
11
+ def controller_pointers(value):
12
+ if not isinstance(value, dict) or set(value) != {
13
+ 'sourceSha', 'descriptorSha256', 'controllerDirectories', 'controllerFiles'}:
14
+ return
15
+ if (not isinstance(value['sourceSha'], str) or not GIT_REVISION.fullmatch(value['sourceSha'])
16
+ or not isinstance(value['descriptorSha256'], str)
17
+ or not HEX.fullmatch(value['descriptorSha256'])):
18
+ return
19
+ directories, files = value['controllerDirectories'], value['controllerFiles']
20
+ if (not isinstance(directories, list) or not all(relative_path(p) for p in directories)
21
+ or len(set(directories)) != len(directories)
22
+ or not isinstance(files, dict) or not files
23
+ or not all(relative_path(p) and isinstance(digest, str) and HEX.fullmatch(digest)
24
+ for p, digest in files.items())
25
+ or any(directory == path or directory.startswith(path + '/')
26
+ for directory in directories for path in files)):
27
+ return
28
+ yield ('sourceSha',)
29
+ yield ('descriptorSha256',)
30
+ for path in files:
31
+ yield ('controllerFiles', path)
@@ -38,6 +38,9 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
38
38
  recorded = registry() + exceptions(history.selected(EXCEPTIONS))
39
39
  resolver = GitHubResolver()
40
40
  public_blobs = {e['oid'] for c in history.public_commits for e in history.entries(c)}
41
+ # A file byte-identical to the same path on the public base is already public.
42
+ public_paths = {e['path']: e['oid'] for e in history.entries(history.public_commit)
43
+ if e['kind'] == 'blob'} if history.public_commit else {}
41
44
  report['history']['preExistingCommits'] = sorted(history.public_commits)
42
45
  report['history']['newlyPublicCommits'] = [c for c in history.commits if c not in history.public_commits]
43
46
  seen_trees, seen_blobs, inspected = set(), set(), {}
@@ -73,7 +76,7 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
73
76
  if key not in inspected:
74
77
  inspected[key] = text_rules(history.blob(oid), path, config_tree, resolver, hashes)
75
78
  rules, provenance = inspected[key]
76
- entry_scope = 'pre-existing' if scope != 'release-tree' and oid in public_blobs else scope
79
+ entry_scope = 'pre-existing' if (scope != 'release-tree' and oid in public_blobs) or public_paths.get(path) == oid else scope
77
80
  for rule in rules:
78
81
  add(rule, path, commit, oid, entry_scope)
79
82
  if provenance:
@@ -92,8 +95,10 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
92
95
  entry = entries_by_commit[history.commit].get(path)
93
96
  oid = entry['oid'] if entry and entry['kind'] == 'blob' and history.blob(entry['oid']) == data else None
94
97
  rules, _ = text_rules(data, path, selected_context, resolver, hashes)
98
+ public_oid = public_paths.get(path)
99
+ scope = 'pre-existing' if public_oid and (public_oid == oid or history.blob(public_oid) == data) else 'working-tree'
95
100
  for rule in rules:
96
- add(rule, path, history.commit, oid, 'working-tree')
101
+ add(rule, path, history.commit, oid, scope)
97
102
  if path == 'LICENSE':
98
103
  for rule in license_rules(data, recorded):
99
104
  add(rule, path, history.commit, oid, 'working-tree')
@@ -109,7 +114,12 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
109
114
  path = Path(path).resolve().relative_to(Path(history.repo).resolve()).as_posix()
110
115
  entry = entries_by_commit.get(commit, {}).get(path)
111
116
  oid = entry['oid'] if entry and entry['kind'] == 'blob' else None
112
- scope = 'working-tree' if mode == 'dir' else 'release-tree' if commit == history.commit else 'pre-existing' if oid in public_blobs else 'newly-public'
117
+ if mode == 'dir':
118
+ public_oid = public_paths.get(path)
119
+ held = working_regular(Path(history.repo), path) if public_oid else None
120
+ scope = 'pre-existing' if held and history.blob(public_oid) == held[0] else 'working-tree'
121
+ else:
122
+ scope = 'pre-existing' if (oid and public_paths.get(path) == oid) or (commit != history.commit and oid in public_blobs) else 'release-tree' if commit == history.commit else 'newly-public'
113
123
  if hit['RuleID'] in ('generic-api-key', 'hex-api-key'):
114
124
  identity_scope = 'working-tree' if mode == 'dir' else commit
115
125
  scoped_entries = entries_by_commit.get(commit, {})
@@ -0,0 +1,283 @@
1
+ """Find the pixel and sample data inside an image or video file.
2
+
3
+ A file is media only when its bytes walk as one complete container, from the
4
+ signature to the end of the file, allowing only NUL padding after it. The path
5
+ and extension never decide, so text renamed .gif, or a valid image with text
6
+ appended, is not media. Each walker returns where its container ends and the
7
+ (start, end) spans of its pixel or sample data (compressed samples and colour
8
+ tables), or None. Everything outside those spans, such as comments, XMP and
9
+ tags, is still text to inspect.
10
+ """
11
+
12
+
13
+ def gif(data):
14
+ if data[:6] not in (b'GIF87a', b'GIF89a') or len(data) < 13:
15
+ return None
16
+ # Colour tables are pixel data too: palette bytes can spell an address.
17
+ offset = 13 + ((3 << ((data[10] & 7) + 1)) if data[10] & 0x80 else 0)
18
+ samples = [(13, offset)]
19
+ while offset < len(data):
20
+ kind = data[offset]
21
+ if kind == 0x3B:
22
+ return offset + 1, samples
23
+ if kind == 0x21:
24
+ offset += 2
25
+ elif kind == 0x2C:
26
+ if offset + 10 > len(data):
27
+ return None
28
+ flags = data[offset + 9]
29
+ # Descriptor, local colour table, then the LZW minimum code size.
30
+ table = offset + 10 + ((3 << ((flags & 7) + 1)) if flags & 0x80 else 0)
31
+ samples.append((offset + 10, table))
32
+ offset = table + 1
33
+ else:
34
+ return None
35
+ # Data sub-blocks: a length byte, that many bytes, ending at length zero.
36
+ start = offset
37
+ while True:
38
+ if offset >= len(data):
39
+ return None
40
+ size = data[offset]
41
+ offset += 1 + size
42
+ if not size:
43
+ break
44
+ if kind == 0x2C:
45
+ samples.append((start, offset))
46
+ return None
47
+
48
+
49
+ def png(data):
50
+ if not data.startswith(b'\x89PNG\r\n\x1a\n'):
51
+ return None
52
+ offset = 8
53
+ samples = []
54
+ while offset + 12 <= len(data):
55
+ length = int.from_bytes(data[offset:offset + 4], 'big')
56
+ kind = data[offset + 4:offset + 8]
57
+ if kind in (b'PLTE', b'IDAT', b'fdAT'):
58
+ samples.append((offset + 8, offset + 8 + length))
59
+ offset += 12 + length
60
+ if kind == b'IEND':
61
+ return offset, samples
62
+ return None
63
+
64
+
65
+ def jpeg(data):
66
+ # A phone stores more images after the first one's end (MPF depth and
67
+ # gain maps): each is walked, and their samples are pixels too.
68
+ walked = jpeg_image(data, 0)
69
+ if walked is None:
70
+ return None
71
+ end, samples = walked
72
+ while data.startswith(b'\xff\xd8', end) and (following := jpeg_image(data, end)):
73
+ end, more = following
74
+ samples += more
75
+ return end, samples
76
+
77
+
78
+ def jpeg_image(data, offset):
79
+ if not data.startswith(b'\xff\xd8', offset):
80
+ return None
81
+ offset += 2
82
+ samples = []
83
+ while offset + 2 <= len(data):
84
+ if data[offset] != 0xFF:
85
+ return None
86
+ marker = data[offset + 1]
87
+ if marker == 0xFF:
88
+ offset += 1
89
+ continue
90
+ if marker == 0xD9:
91
+ return offset + 2, samples
92
+ if 0xD0 <= marker <= 0xD7 or marker == 0x01:
93
+ offset += 2
94
+ continue
95
+ if offset + 4 > len(data):
96
+ return None
97
+ offset += 2 + int.from_bytes(data[offset + 2:offset + 4], 'big')
98
+ if marker == 0xDA:
99
+ # Entropy-coded data runs to the next marker other than a stuffed
100
+ # zero or a restart marker.
101
+ start = offset
102
+ while True:
103
+ offset = data.find(b'\xff', offset)
104
+ if offset < 0 or offset + 1 >= len(data):
105
+ return None
106
+ if data[offset + 1] == 0 or 0xD0 <= data[offset + 1] <= 0xD7:
107
+ offset += 2
108
+ else:
109
+ break
110
+ samples.append((start, offset))
111
+ return None
112
+
113
+
114
+ WEBP_SAMPLES = (b'VP8 ', b'VP8L', b'ALPH', b'ANMF')
115
+
116
+
117
+ def webp(data):
118
+ if len(data) < 20 or data[:4] != b'RIFF' or data[8:12] != b'WEBP':
119
+ return None
120
+ end = int.from_bytes(data[4:8], 'little') + 8
121
+ offset = 12
122
+ samples = []
123
+ while offset + 8 <= end:
124
+ size = int.from_bytes(data[offset + 4:offset + 8], 'little')
125
+ if data[offset:offset + 4] in WEBP_SAMPLES:
126
+ samples.append((offset + 8, offset + 8 + size))
127
+ offset += 8 + size + (size & 1)
128
+ return (end, samples) if offset == end else None
129
+
130
+
131
+ # Major brands of MP4, QuickTime, 3GPP and HEIF/AVIF files.
132
+ ISO_BRANDS = {b'isom', b'iso2', b'iso4', b'iso5', b'iso6', b'mp41', b'mp42', b'avc1', b'dash',
133
+ b'M4V ', b'M4A ', b'M4VH', b'M4VP', b'qt ', b'3gp4', b'3gp5', b'3gp6', b'3g2a',
134
+ b'mif1', b'msf1', b'avif', b'avis', b'heic', b'heix', b'heim', b'heis', b'hevc', b'hevx'}
135
+
136
+
137
+ def iso_media(data):
138
+ # MP4 and AVIF share ISO base media boxes, opened by a file-type box with
139
+ # an explicit size and a known major brand.
140
+ if len(data) < 16 or data[4:8] != b'ftyp' or data[8:12] not in ISO_BRANDS:
141
+ return None
142
+ if int.from_bytes(data[:4], 'big') < 16:
143
+ return None
144
+ offset = 0
145
+ kinds, samples = set(), []
146
+ while offset + 8 <= len(data):
147
+ size = int.from_bytes(data[offset:offset + 4], 'big')
148
+ kind = data[offset + 4:offset + 8]
149
+ header = 8
150
+ if size == 1:
151
+ if offset + 16 > len(data):
152
+ return None
153
+ size, header = int.from_bytes(data[offset + 8:offset + 16], 'big'), 16
154
+ elif size == 0:
155
+ # Only a last box after the file type may run to the end.
156
+ size = len(data) - offset
157
+ if size < header:
158
+ return None
159
+ kinds.add(kind)
160
+ if kind == b'mdat':
161
+ samples.append((offset + header, offset + size))
162
+ offset += size
163
+ # A movie has its moov box; a still image (HEIF/AVIF) has meta.
164
+ if offset != len(data) or not kinds & {b'moov', b'meta'}:
165
+ return None
166
+ return offset, samples
167
+
168
+
169
+ def ebml_number(data, offset, marker):
170
+ # A variable-length EBML number; the marker bit stays in IDs, not sizes.
171
+ if offset >= len(data) or not data[offset]:
172
+ return None, offset
173
+ length = 9 - data[offset].bit_length()
174
+ if offset + length > len(data):
175
+ return None, offset
176
+ value = int.from_bytes(data[offset:offset + length], 'big')
177
+ if not marker:
178
+ value &= (1 << (7 * length)) - 1
179
+ if value == (1 << (7 * length)) - 1:
180
+ value = -1
181
+ return value, offset + length
182
+
183
+
184
+ SEGMENT = 0x18538067
185
+ CLUSTER = 0x1F43B675
186
+ # Segment children: a Cluster of unknown size ends at the next one of these.
187
+ SEGMENT_CHILDREN = {CLUSTER, 0x114D9B74, 0x1549A966, 0x1654AE6B, 0x1C53BB6B,
188
+ 0x1941A469, 0x1043A770, 0x1254C367}
189
+
190
+
191
+ def ebml_element(data, offset):
192
+ element, content = ebml_number(data, offset, True)
193
+ size, content = ebml_number(data, content, False)
194
+ return element, content, size
195
+
196
+
197
+ def open_cluster(data, offset, end):
198
+ # A live Cluster of unknown size runs to the next Segment child.
199
+ start = offset
200
+ while offset < end:
201
+ element, content, size = ebml_element(data, offset)
202
+ if element in SEGMENT_CHILDREN:
203
+ break
204
+ if element is None or size is None or size == -1:
205
+ return None
206
+ offset = content + size
207
+ return offset - start
208
+
209
+
210
+ def webm_segment(data, offset, end):
211
+ # Each Cluster's blocks are the samples; other Segment children are not.
212
+ samples = []
213
+ while offset < end:
214
+ element, content, size = ebml_element(data, offset)
215
+ if element is None or size is None:
216
+ return None
217
+ if size == -1:
218
+ size = open_cluster(data, content, end) if element == CLUSTER else None
219
+ if size is None:
220
+ return None
221
+ if element == CLUSTER:
222
+ samples.append((content, content + size))
223
+ offset = content + size
224
+ return samples if offset == end else None
225
+
226
+
227
+ def webm(data):
228
+ if not data.startswith(b'\x1a\x45\xdf\xa3') or b'webm' not in data[:64]:
229
+ return None
230
+ offset, samples = 0, []
231
+ while offset < len(data):
232
+ element, content, size = ebml_element(data, offset)
233
+ if element is None or size is None:
234
+ return None
235
+ # Only a live Segment may leave its size unknown: it runs to the end.
236
+ if size == -1:
237
+ if element != SEGMENT:
238
+ return None
239
+ size = len(data) - content
240
+ if element == SEGMENT:
241
+ segment = webm_segment(data, content, content + size)
242
+ if segment is None:
243
+ return None
244
+ samples += segment
245
+ offset = content + size
246
+ return (offset, samples) if offset == len(data) else None
247
+
248
+
249
+ def ico(data):
250
+ if len(data) < 6 or data[:4] != b'\0\0\1\0':
251
+ return None
252
+ count = int.from_bytes(data[4:6], 'little')
253
+ images = []
254
+ for index in range(count):
255
+ entry = 6 + 16 * index
256
+ if entry + 16 > len(data):
257
+ return None
258
+ images.append((int.from_bytes(data[entry + 12:entry + 16], 'little'),
259
+ int.from_bytes(data[entry + 8:entry + 12], 'little')))
260
+ # Image data follows the directory with no gap. An embedded PNG keeps its
261
+ # own compressed samples; bitmap pixels are stored as they are.
262
+ offset = 6 + 16 * count
263
+ samples = []
264
+ for start, size in sorted(images):
265
+ if start != offset or not size:
266
+ return None
267
+ image = png(data[start:start + size])
268
+ if image and image[0] == size:
269
+ samples += [(start + a, start + b) for a, b in image[1]]
270
+ offset += size
271
+ return (offset, samples) if count else None
272
+
273
+
274
+ FORMATS = (gif, png, jpeg, webp, iso_media, webm, ico)
275
+
276
+
277
+ def sample_spans(data):
278
+ """The pixel and sample spans of a complete media file, or None."""
279
+ for walk in FORMATS:
280
+ walked = walk(data)
281
+ if walked and walked[0] <= len(data) and not data[walked[0]:].strip(b'\0'):
282
+ return [(start, min(end, walked[0])) for start, end in walked[1]]
283
+ return None