@runeditrun/rer 0.2.0 → 0.2.1
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 +3 -2
- package/cli/README.md +4 -2
- package/cli/contract.mjs +2 -2
- package/cli/deploy.mjs +17 -3
- package/cli/detached-server.mjs +51 -0
- package/cli/hosted-output.mjs +2 -2
- package/cli/hydration/check.mjs +2 -2
- package/cli/inspect.mjs +1 -1
- package/cli/main.mjs +1 -0
- package/cli/managed-server.mjs +12 -5
- package/cli/parse.mjs +3 -2
- package/cli/release.mjs +1 -0
- package/cli/site-bindings.mjs +291 -0
- package/cli/site-browser-objects.mjs +36 -0
- package/cli/site-check.mjs +46 -31
- package/cli/site-client.mjs +50 -0
- package/cli/site-data.mjs +54 -0
- package/cli/site-entities.json +1 -0
- package/cli/site-input-assets.mjs +58 -0
- package/cli/site-inputs.mjs +79 -0
- package/cli/site-markup.mjs +102 -0
- package/cli/site-private.mjs +36 -0
- package/cli/site-references.mjs +93 -0
- package/cli/site-render.mjs +7 -5
- package/cli/site-syntax.mjs +21 -0
- package/cli/site.mjs +32 -11
- package/global/CHANGELOG.md +25 -0
- package/global/README.md +3 -2
- package/global/main.mjs +2 -0
- package/global/output.mjs +12 -0
- package/global/rer.mjs +3 -0
- package/modules/deploy-kit/deploy.mjs +5 -2
- package/modules/deploy-kit/diagnostics.mjs +55 -0
- package/modules/deploy-kit/owner-cli.mjs +10 -4
- package/modules/deploy-kit/owner.mjs +3 -0
- package/modules/deploy-kit/provider.mjs +45 -6
- package/modules/operator-auth/accounts/native.mjs +1 -1
- package/modules/operator-auth/dev/badge.mjs +4 -1
- package/modules/operator-auth/index.d.mts +4 -9
- package/modules/operator-auth/page.mjs +1 -1
- package/modules/operator-auth/service.mjs +1 -0
- package/modules/operator-auth/signin/index.d.mts +11 -0
- package/modules/operator-auth/signin/index.mjs +10 -7
- package/modules/release/README.md +63 -0
- package/modules/release/keygen.mjs +18 -0
- package/modules/release/produce.mjs +9 -0
- package/modules/release/sign.mjs +44 -0
- package/modules/release/signature.mjs +336 -0
- package/modules/release/signing-cli.mjs +56 -0
- package/modules/release/signing-io.mjs +61 -0
- package/modules/release/signing-key.mjs +22 -0
- package/modules/release/statement.mjs +80 -0
- package/modules/release/vectors/generate.mjs +381 -0
- package/modules/release/vectors/release-signature-vectors.json +2305 -0
- package/modules/release/verify.mjs +27 -0
- package/onboarding/production-assets.mjs +23 -0
- package/onboarding/production-runtime.mjs +8 -4
- package/package.json +2 -1
- package/scripts/publish-check/README.md +27 -17
- package/scripts/publish-check/content_identities.py +14 -233
- package/scripts/publish-check/git_history.py +2 -2
- package/scripts/publish-check/gitleaks.toml +1 -1
- package/scripts/publish-check/history_scan.py +6 -4
- package/scripts/publish-check/hygiene_config.py +3 -1
- package/scripts/publish-check/public_hygiene.py +29 -5
- package/scripts/publish-check/publication-config.json +3 -1
- package/scripts/publish-check/source_metadata.py +62 -0
- package/scripts/publish-check/source_shapes.py +91 -0
- package/scripts/publish-check/tree_scan.py +2 -2
- package/scripts/publish-check/source_hashes.mjs +0 -6
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { lstatSync } from 'node:fs';
|
|
3
|
+
import { loadAnchors, verifyRelease } from './signature.mjs';
|
|
4
|
+
import { readArtifact } from './statement.mjs';
|
|
5
|
+
import { SIGNATURE_FILE, readBounded, safePath, refuse } from './signing-io.mjs';
|
|
6
|
+
|
|
7
|
+
export async function verifyArtifact(artifact, anchors, environment, bytes, highest) {
|
|
8
|
+
const result = await verifyRelease(bytes, anchors, { environment, expect: artifact.subject,
|
|
9
|
+
files: artifact.files, highest, unsigned: { project: artifact.subject.project, version: artifact.subject.version } });
|
|
10
|
+
if (result.verdict !== 'VERIFIED') refuse(result.code ?? 'SIGNATURE_MISSING');
|
|
11
|
+
return result;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function receipt(options) {
|
|
15
|
+
const environment = options.environment ?? 'production';
|
|
16
|
+
const anchors = await loadAnchors(readBounded(options.anchors, 4 * 1024 * 1024), environment);
|
|
17
|
+
const signaturePath = resolve(safePath(options.artifact), SIGNATURE_FILE);
|
|
18
|
+
let bytes = null;
|
|
19
|
+
try { lstatSync(signaturePath); bytes = readBounded(signaturePath, 65536); }
|
|
20
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
21
|
+
// Authenticate before interpreting artifact bytes. Base never accepts a
|
|
22
|
+
// legacy unsigned floor; the shared verifier refuses null without one.
|
|
23
|
+
await verifyRelease(bytes, anchors, { environment });
|
|
24
|
+
const artifact = await readArtifact(options.artifact);
|
|
25
|
+
const highest = options.after ? { version: options.after.split(':')[0], statementSha256: options.after.split(':')[1] } : undefined;
|
|
26
|
+
await verifyArtifact(artifact, anchors, environment, bytes, highest);
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
// Miniflare computes its asset manifest (and ETags) at setOptions, not per
|
|
4
|
+
// request. Refresh it when built assets change; keep the same durable state.
|
|
5
|
+
// Workers/modules remain fixed production output: no source build runs here.
|
|
6
|
+
export function watchProductionAssets(runtime, options, convert, onError) {
|
|
7
|
+
const directories = [...new Set((options.workers ?? [options]).map(worker => worker.assets?.directory).filter(Boolean))];
|
|
8
|
+
const watchers = [];
|
|
9
|
+
let timer, pending = Promise.resolve(), closed = false;
|
|
10
|
+
const refresh = () => {
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
timer = setTimeout(() => {
|
|
13
|
+
pending = pending.then(async () => { if (!closed) await runtime.setOptions(convert(options)); }).catch(error => { if (!closed) onError(error); });
|
|
14
|
+
}, 50);
|
|
15
|
+
};
|
|
16
|
+
try {
|
|
17
|
+
for (const directory of directories) {
|
|
18
|
+
const watcher = watch(directory,{recursive:true},refresh);
|
|
19
|
+
watcher.on('error',onError); watchers.push(watcher);
|
|
20
|
+
}
|
|
21
|
+
} catch (error) { for (const watcher of watchers) watcher.close(); throw error; }
|
|
22
|
+
return async () => {closed=true;clearTimeout(timer);for(const watcher of watchers)watcher.close();await pending;};
|
|
23
|
+
}
|
|
@@ -2,13 +2,14 @@ import {spawn} from 'node:child_process';
|
|
|
2
2
|
import {fileURLToPath} from 'node:url';
|
|
3
3
|
import {resolve} from 'node:path';
|
|
4
4
|
import {projectMiniflare} from './production-options.mjs';
|
|
5
|
+
import {watchProductionAssets} from './production-assets.mjs';
|
|
5
6
|
|
|
6
7
|
// Preview's resident process. Wrangler resolves the Worker's options in a
|
|
7
8
|
// short-lived child that exits before serving starts; this process then holds
|
|
8
9
|
// only the pinned Miniflare, which owns one workerd and no bundler or
|
|
9
|
-
//
|
|
10
|
-
let planner,stopping,runtime;
|
|
11
|
-
const stop=()=>stopping??=(async()=>{planner?.kill('SIGTERM');await runtime?.dispose();})();
|
|
10
|
+
// source/config watcher. Built-asset changes refresh its manifest.
|
|
11
|
+
let planner,stopping,runtime,closeAssets;
|
|
12
|
+
const stop=()=>stopping??=(async()=>{planner?.kill('SIGTERM');await closeAssets?.();await runtime?.dispose();})();
|
|
12
13
|
process.on('SIGINT',()=>void stop());process.on('SIGTERM',()=>void stop());
|
|
13
14
|
async function plan(){
|
|
14
15
|
let text='';
|
|
@@ -25,6 +26,9 @@ try{
|
|
|
25
26
|
const {Miniflare,convertV4MiniflareOptions}=await projectMiniflare(process.cwd());
|
|
26
27
|
runtime=new Miniflare({...convertV4MiniflareOptions(options),resourcePersistencePath:resolve(process.cwd(),'.wrangler/state/v3')});
|
|
27
28
|
if(stopping)await runtime.dispose();
|
|
28
|
-
else
|
|
29
|
+
else {
|
|
30
|
+
await runtime.ready;
|
|
31
|
+
if (!stopping) closeAssets=watchProductionAssets(runtime,options,value=>({...convertV4MiniflareOptions(value),resourcePersistencePath:resolve(process.cwd(),'.wrangler/state/v3')}),error=>{process.stderr.write(`Preview asset refresh failed: ${error.message}\n`);process.exitCode=1;void stop();});
|
|
32
|
+
}
|
|
29
33
|
}
|
|
30
34
|
}catch(error){await stop();throw error;}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runeditrun/rer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "The rer command: install, set up, run and launch Run Edit Run checkouts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"./modules/release/export": "./modules/release/export.mjs",
|
|
71
71
|
"./modules/release/publication-scan": "./modules/release/publication-scan.mjs",
|
|
72
72
|
"./modules/release/source": "./modules/release/source.mjs",
|
|
73
|
+
"./modules/release/signature": "./modules/release/signature.mjs",
|
|
73
74
|
"./modules/operator-auth": "./modules/operator-auth/index.mjs",
|
|
74
75
|
"./modules/operator-auth/accounts/native": "./modules/operator-auth/accounts/native.mjs",
|
|
75
76
|
"./modules/operator-auth/dev": "./modules/operator-auth/dev/index.mjs",
|
|
@@ -8,32 +8,42 @@ blob reads to system Git (`rev-parse`, `rev-list`, `ls-tree`, `cat-file --batch`
|
|
|
8
8
|
its report private until translating it into the value-free v3 envelope.
|
|
9
9
|
Hexadecimal credential assignments also use a Gitleaks rule without generic
|
|
10
10
|
entropy or English stopword filters. Both assignment rules go through the same
|
|
11
|
-
location-bound Source/module identity
|
|
11
|
+
location-bound Source/module identity classification; provider and environment findings
|
|
12
12
|
remain independent.
|
|
13
13
|
`tree_scan.py` applies the kept text and licence policy. `history_scan.py`
|
|
14
14
|
assigns release, working-tree and historical scopes. The Node entry point bounds
|
|
15
15
|
the whole operation and stops its private process group on timeout/cancellation.
|
|
16
16
|
|
|
17
|
+
Git message records carry their kind explicitly. Only commit records enable the
|
|
18
|
+
bounded public coauthor address rule described in the command guide; a file named
|
|
19
|
+
`@commit-message` has no special status. `public_hygiene.py` matches complete raw
|
|
20
|
+
`Co-Authored-By` lines in the final trailer paragraph case-insensitively and
|
|
21
|
+
masks only the accepted address for email inspection, leaving names, other
|
|
22
|
+
addresses and every other rule intact.
|
|
23
|
+
|
|
17
24
|
Binary metadata decoding, Git protocol/object/ref modelling, frozen comparison,
|
|
18
25
|
resumable inspection and their tests have been removed. Tests live in `tests.py`
|
|
19
26
|
and `coverage_tests.py` and construct disposable repositories with actual Git.
|
|
20
27
|
|
|
21
|
-
`content_identities.py`
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
28
|
+
`content_identities.py` binds generic-api-key and hex-api-key occurrences to
|
|
29
|
+
exact JSON value locations. `source_metadata.py` enumerates digest fields in the
|
|
30
|
+
formats emitted by `edition-authoring/cli.mjs` and `sources.mjs`: lock manifest,
|
|
31
|
+
toolchain, parent/baseline pins and file SHA-256 values; retained archive keys,
|
|
32
|
+
content digests and dependency pins; and the source origins' revision or
|
|
33
|
+
projection identities. These are format classifications independent of selected
|
|
34
|
+
graphs, canonical serialization, archive preimages and authoring freshness.
|
|
35
|
+
`source_shapes.py` first checks the documented local record types, immutable
|
|
36
|
+
origin identity, source selections and file/pin records. A malformed archive or
|
|
37
|
+
lock grants no identity roles; unknown extra fields never gain a role.
|
|
38
|
+
`provenance_pins.py` keeps the existing module revision classification.
|
|
39
|
+
|
|
40
|
+
`json_identity.py` rejects duplicate members and non-JSON constants and binds
|
|
41
|
+
native findings to exact string byte spans. Only documented structural fields
|
|
42
|
+
with the declared digest length qualify. An extra credential field, arbitrary
|
|
43
|
+
nested field or copy of the digest on the same minified line remains a finding.
|
|
44
|
+
Provider and known environment findings are independent. Native git/dir matches
|
|
45
|
+
stay in the private temporary report and memory; values are never added to the
|
|
46
|
+
public report. Authoring's integrity checks remain unchanged.
|
|
37
47
|
|
|
38
48
|
`copy_identities.py` proves the maintained `base-copies.json` and portable
|
|
39
49
|
`base/PROVENANCE.json` copy-inventory SHA-256 values
|
|
@@ -1,249 +1,30 @@
|
|
|
1
|
-
"""Classify
|
|
2
|
-
|
|
3
|
-
This is classification, not Source authoring validation. A stale lock still
|
|
4
|
-
fails the authoring check. Missing original preimages remain ordinary findings.
|
|
5
|
-
"""
|
|
6
|
-
import base64
|
|
7
|
-
import hashlib
|
|
8
|
-
import re
|
|
9
|
-
import json
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
import subprocess
|
|
12
|
-
from json_identity import Document, HEX
|
|
1
|
+
"""Classify documented JSON identities at exact native-match locations."""
|
|
2
|
+
from json_identity import Document
|
|
13
3
|
from provenance_pins import module_pins
|
|
14
4
|
from copy_identities import copy_pointers
|
|
15
|
-
|
|
16
|
-
ARCHIVE = re.compile(r'\.edition/sources/([a-f0-9]{64})\.json\Z')
|
|
5
|
+
from source_metadata import ARCHIVE, source_pointers
|
|
17
6
|
|
|
18
7
|
|
|
19
8
|
def candidate(path):
|
|
20
9
|
return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path))
|
|
21
10
|
|
|
22
11
|
|
|
23
|
-
def safe_path(value):
|
|
24
|
-
return (isinstance(value, str) and value and not re.search(r'[\\\x00-\x1f\x7f]', value)
|
|
25
|
-
and all(part not in ('', '.', '..', '.git') for part in value.split('/')))
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def valid_files(files):
|
|
29
|
-
if not isinstance(files, dict) or not files:
|
|
30
|
-
return False
|
|
31
|
-
for name, file in files.items():
|
|
32
|
-
if (not safe_path(name) or not isinstance(file, dict) or not isinstance(file.get('data'), str)
|
|
33
|
-
or type(file.get('executable')) is not bool):
|
|
34
|
-
return False
|
|
35
|
-
try:
|
|
36
|
-
if base64.b64encode(base64.b64decode(file['data'], validate=True)).decode() != file['data']:
|
|
37
|
-
return False
|
|
38
|
-
except ValueError:
|
|
39
|
-
return False
|
|
40
|
-
return True
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def same_json(left, right):
|
|
44
|
-
# Python equates booleans and numbers; Source JSON identities do not.
|
|
45
|
-
return json.dumps(left, sort_keys=True) == json.dumps(right, sort_keys=True)
|
|
46
|
-
|
|
47
|
-
|
|
48
12
|
class ContentIdentities:
|
|
49
13
|
def __init__(self, files, read_regular=None):
|
|
50
|
-
self.documents, self.allowed
|
|
14
|
+
self.documents, self.allowed = {}, {}
|
|
51
15
|
for path, data in files.items():
|
|
52
|
-
if candidate(path):
|
|
53
|
-
try:
|
|
54
|
-
self.documents[path] = Document(data)
|
|
55
|
-
except (ValueError, TypeError, IndexError, RecursionError, UnicodeError):
|
|
56
|
-
pass
|
|
57
|
-
if not self.documents:
|
|
58
|
-
return
|
|
59
|
-
for path, document in self.documents.items():
|
|
60
|
-
for pointer in module_pins(path, document.value):
|
|
61
|
-
self.allow(path, pointer)
|
|
62
|
-
if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
|
|
63
|
-
for pointer in copy_pointers(document.value, read_regular):
|
|
64
|
-
self.allow(path, pointer)
|
|
65
|
-
recipe = self.document('edition.json')
|
|
66
|
-
origins = [self.origin(recipe.get('parent'), True)]
|
|
67
|
-
components = recipe.get('components', [])
|
|
68
|
-
if isinstance(components, list):
|
|
69
|
-
for component in components:
|
|
70
|
-
source = component.get('source') if isinstance(component, dict) else None
|
|
71
|
-
if isinstance(source, dict):
|
|
72
|
-
origins.append(self.origin(source if source.get('kind') == 'git' else source.get('origin')))
|
|
73
|
-
values = [recipe, *(v for v in origins if v is not None)]
|
|
74
|
-
for path, document in self.documents.items():
|
|
75
|
-
saved = document.value
|
|
76
|
-
if ARCHIVE.fullmatch(path) and isinstance(saved, dict):
|
|
77
|
-
values.extend([saved, saved.get('origin'), saved.get('files')])
|
|
78
|
-
self.hashes = {}
|
|
79
|
-
if values:
|
|
80
|
-
try:
|
|
81
|
-
result = subprocess.run(['node', str(Path(__file__).with_name('source_hashes.mjs'))],
|
|
82
|
-
input=json.dumps(values).encode(), capture_output=True, timeout=60, check=True)
|
|
83
|
-
hashes = json.loads(result.stdout)
|
|
84
|
-
if len(hashes) != len(values) or not all(isinstance(h, str) and HEX.fullmatch(h) for h in hashes):
|
|
85
|
-
return
|
|
86
|
-
self.hashes = {json.dumps(v): h for v, h in zip(values, hashes)}
|
|
87
|
-
except (OSError, ValueError, subprocess.SubprocessError):
|
|
88
|
-
return
|
|
89
|
-
# Authoring pins hash(json(saved)); a raw-byte pin alone cannot prove a
|
|
90
|
-
# differently encoded retained archive, even when its fields agree.
|
|
91
|
-
for path, document in self.documents.items():
|
|
92
|
-
saved, match = document.value, ARCHIVE.fullmatch(path)
|
|
93
|
-
if (match and isinstance(saved, dict) and saved.get('key') == match[1]
|
|
94
|
-
and isinstance(saved.get('origin'), dict) and saved['origin']
|
|
95
|
-
and valid_files(saved.get('files'))
|
|
96
|
-
and isinstance(saved.get('dependencies', []), list)
|
|
97
|
-
and saved['key'] == self.digest(saved['origin'])
|
|
98
|
-
and saved.get('digest') == self.digest(saved['files'])
|
|
99
|
-
and hashlib.sha256(document.data).hexdigest() == self.digest(saved)):
|
|
100
|
-
self.archives[saved['key']] = (saved, hashlib.sha256(document.data).hexdigest())
|
|
101
|
-
self.references()
|
|
102
|
-
|
|
103
|
-
def digest(self, value):
|
|
104
|
-
return self.hashes.get(json.dumps(value))
|
|
105
|
-
|
|
106
|
-
def allow(self, path, pointer):
|
|
107
|
-
self.allowed.setdefault(path, set()).add(pointer)
|
|
108
|
-
|
|
109
|
-
def document(self, path):
|
|
110
|
-
doc = self.documents.get(path)
|
|
111
|
-
return doc.value if doc and isinstance(doc.value, dict) else {}
|
|
112
|
-
|
|
113
|
-
def pin(self, pin, key):
|
|
114
|
-
if not isinstance(pin, dict) or not isinstance(key, str) or key not in self.archives:
|
|
115
|
-
return False
|
|
116
|
-
archive, raw_sha = self.archives[key]
|
|
117
|
-
return (pin.get('key') == key and pin.get('digest') == archive['digest']
|
|
118
|
-
and pin.get('sha256') == raw_sha)
|
|
119
|
-
|
|
120
|
-
def reference(self, ref, parent=False):
|
|
121
|
-
if not isinstance(ref, dict):
|
|
122
|
-
return None
|
|
123
|
-
if not self.valid_selection(ref):
|
|
124
|
-
return None
|
|
125
|
-
selection = dict(path=ref.get('path') or '.', paths=ref.get('paths') or None,
|
|
126
|
-
excludeSnapshots=True if parent else bool(ref.get('excludeSnapshots')))
|
|
127
|
-
projection = ref.get('projection')
|
|
128
|
-
if isinstance(projection, dict) and set(projection) == {'key', 'sha256'}:
|
|
129
|
-
key = projection['key']
|
|
130
|
-
if not isinstance(key, str) or key not in self.archives:
|
|
131
|
-
return None
|
|
132
|
-
saved, raw_sha = self.archives[key]
|
|
133
|
-
if (projection['sha256'] == raw_sha and same_json(saved['origin'].get('selection'), selection)):
|
|
134
|
-
return key
|
|
135
|
-
return None
|
|
136
|
-
origin = self.origin(ref, parent)
|
|
137
|
-
key = self.digest(origin)
|
|
138
|
-
if key in self.archives and same_json(self.archives[key][0]['origin'], origin):
|
|
139
|
-
return key
|
|
140
|
-
return None
|
|
141
|
-
|
|
142
|
-
@staticmethod
|
|
143
|
-
def valid_selection(ref):
|
|
144
|
-
return (ref.get('path', '.') == '.' or safe_path(ref.get('path'))) and (
|
|
145
|
-
ref.get('paths') is None or isinstance(ref['paths'], list) and bool(ref['paths'])
|
|
146
|
-
and all(safe_path(p) for p in ref['paths'])) and (
|
|
147
|
-
'excludeSnapshots' not in ref or type(ref['excludeSnapshots']) is bool)
|
|
148
|
-
|
|
149
|
-
@staticmethod
|
|
150
|
-
def origin(ref, parent=False):
|
|
151
|
-
if not isinstance(ref, dict) or not ContentIdentities.valid_selection(ref):
|
|
152
|
-
return None
|
|
153
|
-
projection = ref.get('projection')
|
|
154
|
-
if (not isinstance(ref.get('repository'), str) or not ref['repository']
|
|
155
|
-
or ref['repository'].startswith('-')):
|
|
156
|
-
return None
|
|
157
|
-
if 'projection' in ref:
|
|
158
|
-
if ('revision' in ref or not isinstance(projection, dict) or set(projection) != {'schemaVersion', 'originalSha256', 'recipeSha256'}
|
|
159
|
-
or type(projection['schemaVersion']) is not int or projection['schemaVersion'] != 1
|
|
160
|
-
or not all(isinstance(projection[k], str) and HEX.fullmatch(projection[k])
|
|
161
|
-
for k in ('originalSha256', 'recipeSha256'))):
|
|
162
|
-
return None
|
|
163
|
-
identity = dict(projection=projection)
|
|
164
|
-
elif isinstance(ref.get('revision'), str) and re.fullmatch(r'[a-f0-9]{40}', ref['revision']):
|
|
165
|
-
identity = dict(revision=ref['revision'])
|
|
166
|
-
else:
|
|
167
|
-
return None
|
|
168
|
-
return dict(repository=ref['repository'], **identity, path=ref.get('path') or '.',
|
|
169
|
-
paths=ref.get('paths') or None,
|
|
170
|
-
excludeSnapshots=True if parent else bool(ref.get('excludeSnapshots')))
|
|
171
|
-
|
|
172
|
-
def dependency_locations(self, key):
|
|
173
|
-
# Stage the entire selected closure before granting any of its locations.
|
|
174
|
-
# An unresolved later edge must not leave the root or earlier edges allowed.
|
|
175
|
-
pending, visited, locations = [key], set(), []
|
|
176
|
-
while pending:
|
|
177
|
-
key = pending.pop()
|
|
178
|
-
if key in visited:
|
|
16
|
+
if not candidate(path):
|
|
179
17
|
continue
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
locations.append((path, ('key',)))
|
|
184
|
-
for i, pin in enumerate(saved.get('dependencies', [])):
|
|
185
|
-
if not isinstance(pin, dict) or not self.pin(pin, pin.get('key')):
|
|
186
|
-
return None
|
|
187
|
-
locations.append((path, ('dependencies', i, 'key')))
|
|
188
|
-
pending.append(pin['key'])
|
|
189
|
-
return locations
|
|
190
|
-
|
|
191
|
-
def references(self):
|
|
192
|
-
recipe = self.document('edition.json')
|
|
193
|
-
lock = self.document('sources.lock.json')
|
|
194
|
-
if type(recipe.get('schemaVersion')) is not int or recipe['schemaVersion'] != 1 or not isinstance(recipe.get('components'), list):
|
|
195
|
-
return
|
|
196
|
-
components = lock.get('components')
|
|
197
|
-
locked = (type(lock.get('schemaVersion')) is int and lock['schemaVersion'] == 1
|
|
198
|
-
and isinstance(recipe.get('id'), str) and lock.get('edition') == recipe['id']
|
|
199
|
-
and lock.get('manifest') == self.digest(recipe) and isinstance(components, list))
|
|
200
|
-
if not locked:
|
|
201
|
-
return
|
|
202
|
-
def identifiers(entries):
|
|
203
|
-
ids = [entry.get('id') for entry in entries if isinstance(entry, dict)]
|
|
204
|
-
return (len(ids) == len(entries) and all(isinstance(i, str) and i for i in ids)
|
|
205
|
-
and len(set(ids)) == len(ids))
|
|
206
|
-
if not identifiers(recipe['components']) or not identifiers(components):
|
|
207
|
-
return
|
|
208
|
-
by_id = {c['id']: c for c in components}
|
|
209
|
-
if (len(components) != len(recipe['components']) or any(
|
|
210
|
-
c['id'] not in by_id or not same_json(c.get('source'), by_id[c['id']].get('source'))
|
|
211
|
-
for c in recipe['components'])):
|
|
212
|
-
return
|
|
213
|
-
selected = []
|
|
214
|
-
key = self.reference(recipe.get('parent'), parent=True)
|
|
215
|
-
if key and locked and self.pin(lock.get('parent'), key):
|
|
216
|
-
selected.append((key, [
|
|
217
|
-
('edition.json', ('parent', 'projection', 'key')),
|
|
218
|
-
('sources.lock.json', ('parent', 'key')),
|
|
219
|
-
]))
|
|
220
|
-
for index, component in enumerate(recipe['components']):
|
|
221
|
-
if not isinstance(component, dict):
|
|
222
|
-
continue
|
|
223
|
-
source = component.get('source')
|
|
224
|
-
if not isinstance(source, dict):
|
|
225
|
-
continue
|
|
226
|
-
suffix = () if source.get('kind') == 'git' else ('origin',) if source.get('kind') == 'local' else None
|
|
227
|
-
if suffix is None:
|
|
228
|
-
continue
|
|
229
|
-
key = self.reference(source if not suffix else source.get('origin'))
|
|
230
|
-
if not key:
|
|
18
|
+
try:
|
|
19
|
+
document = Document(data)
|
|
20
|
+
except (ValueError, TypeError, IndexError, RecursionError, UnicodeError):
|
|
231
21
|
continue
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
('sources.lock.json', ('components', i, 'source', *suffix, 'projection', 'key')),
|
|
239
|
-
]))
|
|
240
|
-
# Validate each selection independently: unselected archives and unrelated
|
|
241
|
-
# coherent selections cannot invalidate this graph's proof.
|
|
242
|
-
for key, locations in selected:
|
|
243
|
-
dependencies = self.dependency_locations(key)
|
|
244
|
-
if dependencies is not None:
|
|
245
|
-
for path, pointer in locations + dependencies:
|
|
246
|
-
self.allow(path, pointer)
|
|
22
|
+
self.documents[path] = document
|
|
23
|
+
pointers = set(module_pins(path, document.value))
|
|
24
|
+
pointers.update(source_pointers(path, document.value))
|
|
25
|
+
if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
|
|
26
|
+
pointers.update(copy_pointers(document.value, read_regular))
|
|
27
|
+
self.allowed[path] = pointers
|
|
247
28
|
|
|
248
29
|
def matches(self, path, hit):
|
|
249
30
|
document = self.documents.get(path)
|
|
@@ -96,12 +96,12 @@ class History:
|
|
|
96
96
|
records = self.run('log', '-z', '--format=%H%x00%B', *self.revisions).split(b'\0')
|
|
97
97
|
for index in range(0, len(records) - 1, 2):
|
|
98
98
|
if records[index]:
|
|
99
|
-
yield '
|
|
99
|
+
yield 'commit', records[index].decode().strip(), records[index + 1]
|
|
100
100
|
for record in self.run('for-each-ref', '--format=%(objecttype) %(objectname)', 'refs/tags').splitlines():
|
|
101
101
|
kind, oid = record.decode().split()
|
|
102
102
|
if kind == 'tag':
|
|
103
103
|
raw = self.run('cat-file', 'tag', oid)
|
|
104
|
-
yield '
|
|
104
|
+
yield 'tag', oid, raw.partition(b'\n\n')[2]
|
|
105
105
|
|
|
106
106
|
def close(self):
|
|
107
107
|
if self.reader:
|
|
@@ -29,7 +29,7 @@ regexes = ['''\.(?:test|fixture|fake|example|dummy|placeholder|synthetic)[_-][a-
|
|
|
29
29
|
|
|
30
30
|
# SHA-shaped credentials must not disappear because their random bytes contain
|
|
31
31
|
# an English stopword (for example dead/feed) or fall below generic entropy.
|
|
32
|
-
# Source/module identities still require
|
|
32
|
+
# Source/module identities still require exact structural JSON locations in Python.
|
|
33
33
|
[[rules]]
|
|
34
34
|
id = "hex-api-key"
|
|
35
35
|
description = "Hexadecimal credential assignment"
|
|
@@ -128,7 +128,7 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
|
|
|
128
128
|
if identities[identity_scope].matches(path, hit):
|
|
129
129
|
continue
|
|
130
130
|
else:
|
|
131
|
-
#
|
|
131
|
+
# Identity documents use their own exact structural locations.
|
|
132
132
|
held = working_regular(Path(history.repo), path) if mode == 'dir' else read_regular(path)
|
|
133
133
|
if held and checksums[identity_scope].matches(held[0], hit):
|
|
134
134
|
continue
|
|
@@ -146,9 +146,11 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
|
|
|
146
146
|
scope = 'working-tree' if path in working_paths else 'pre-existing' if commit in history.public_commits else 'newly-public'
|
|
147
147
|
lines.append((count, count + length, path, commit, scope))
|
|
148
148
|
count += length
|
|
149
|
-
for
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
for message_type, commit, data in history.messages():
|
|
150
|
+
path = '@' + message_type + '-message'
|
|
151
|
+
report['history']['tagMessages' if message_type == 'tag' else 'commitMessages'] += 1
|
|
152
|
+
rules, _ = text_rules(data, path, selected_context, resolver, hashes,
|
|
153
|
+
commit_message=message_type == 'commit')
|
|
152
154
|
scope = 'release-tree' if commit == history.commit else 'pre-existing' if commit in history.public_commits else 'newly-public'
|
|
153
155
|
for rule in rules:
|
|
154
156
|
add(rule, path, commit, scope=scope)
|
|
@@ -9,7 +9,9 @@ LOCAL_PATH = re.compile(r'''(?x)(?<![^\s("'`=:\[<>{|])(?:[*_]|~~)*(?:(?:(?i:(?a:
|
|
|
9
9
|
CONFIG = '.publish-check/config.json'
|
|
10
10
|
# An alphabetic top-level label ends before a following digit, such as the DER
|
|
11
11
|
# tag after a certificate's email value; other labels keep the full extent.
|
|
12
|
-
|
|
12
|
+
# A period is punctuation unless it starts another host label; never shorten
|
|
13
|
+
# a suffixed host into a reserved/public domain.
|
|
14
|
+
EMAIL = re.compile(r"(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.(?:[A-Za-z]{2,63}(?![A-Za-z-]|\.[A-Za-z0-9-])|[A-Za-z][A-Za-z0-9-]{1,62}(?![A-Za-z0-9-]|\.[A-Za-z0-9-]))|(?<![A-Za-z0-9_.+%-])[A-Za-z0-9_][A-Za-z0-9_.+%'-]{0,63}@(?:example|test|invalid)(?![A-Za-z0-9-]|\.[A-Za-z0-9-])", re.I)
|
|
13
15
|
RULES = {'hygiene.local-path', 'hygiene.github-private', 'hygiene.github-missing',
|
|
14
16
|
'hygiene.email', 'hygiene.internal-reference'}
|
|
15
17
|
|
|
@@ -9,6 +9,29 @@ from hygiene_config import EMAIL, LOCAL_PATH, public_email
|
|
|
9
9
|
from font_licence import without_required_notices
|
|
10
10
|
from trust_bundle import anchor_values
|
|
11
11
|
URI_AUTHORITY = re.compile(r'''(?i)(?a:(?P<scheme>[a-z][a-z0-9+.-]*:))?//(?P<authority>[^/?#\s<>"']+)''')
|
|
12
|
+
# Match raw, complete commit trailer lines; decoded or embedded prose cannot
|
|
13
|
+
# acquire trailer privileges. Only the address span is omitted from email checks.
|
|
14
|
+
COAUTHOR = re.compile(
|
|
15
|
+
rb'^Co-Authored-By:[ \t]+[^<>\r\n]*[^<>\s][ \t]+<(?P<address>'
|
|
16
|
+
rb'noreply@(?:anthropic|github)\.com|'
|
|
17
|
+
rb'[0-9]+\+[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}@users\.noreply\.github\.com'
|
|
18
|
+
rb')>[ \t]*\r?$', re.I | re.M)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def without_public_coauthor_addresses(data):
|
|
22
|
+
# A trailer block is the final paragraph and contains only trailer lines.
|
|
23
|
+
# A quoted attribution followed by ordinary prose stays ordinary body text.
|
|
24
|
+
tail = data.rstrip(b' \t\r\n')
|
|
25
|
+
block = re.split(rb'\r?\n[ \t]*\r?\n', tail)[-1]
|
|
26
|
+
if not all(re.fullmatch(rb'[A-Za-z0-9-]+:[ \t]+[^\r\n]+\r?', line)
|
|
27
|
+
for line in block.split(b'\n')):
|
|
28
|
+
return data
|
|
29
|
+
offset = len(tail) - len(block)
|
|
30
|
+
result = bytearray(data)
|
|
31
|
+
for match in COAUTHOR.finditer(block):
|
|
32
|
+
start, end = match.span('address')
|
|
33
|
+
result[offset + start:offset + end] = b' ' * (end - start)
|
|
34
|
+
return bytes(result)
|
|
12
35
|
|
|
13
36
|
|
|
14
37
|
def email_userinfo(match):
|
|
@@ -25,7 +48,7 @@ def private_emails(text, config):
|
|
|
25
48
|
# URL paths, queries and fragments remain visible to email inspection.
|
|
26
49
|
email_text = URI_AUTHORITY.sub(email_userinfo, without_required_notices(text))
|
|
27
50
|
email_text = re.sub(r'(?i)\b(?a:git@github\.com:)(?=[A-Za-z0-9])', '', email_text)
|
|
28
|
-
# A package manager's patch
|
|
51
|
+
# A package manager's patch filename joins the name and version with an at sign.
|
|
29
52
|
version = r'(?:v?[0-9]+(?:\.[0-9]+)*(?:-[A-Za-z0-9-]+)?|[a-f0-9]{40})(?:\.patch)?(?![A-Za-z0-9_.-])'
|
|
30
53
|
email_text = re.sub(r'(?m)^\s*-?\s*uses:\s*[\"\']?[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+@[^\s\"\']+', '', email_text)
|
|
31
54
|
email_text = re.sub(r'(?<![A-Za-z0-9])(?:@[A-Za-z0-9_.-]+/)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?@' + version, '', email_text)
|
|
@@ -69,11 +92,11 @@ def certificate_metadata(data, bundle, emails, config):
|
|
|
69
92
|
return not Counter(emails) - inside
|
|
70
93
|
|
|
71
94
|
|
|
72
|
-
def inspect(data, path, config, resolver):
|
|
73
|
-
return inspect_text(data, path, config, resolver)
|
|
95
|
+
def inspect(data, path, config, resolver, *, commit_message=False):
|
|
96
|
+
return inspect_text(data, path, config, resolver, commit_message=commit_message)
|
|
74
97
|
|
|
75
98
|
|
|
76
|
-
def inspect_text(data, path, config, resolver):
|
|
99
|
+
def inspect_text(data, path, config, resolver, *, commit_message=False):
|
|
77
100
|
# NUL removal covers UTF-16/32 ASCII content, matching the secret scanner.
|
|
78
101
|
text = decoded_text(data)
|
|
79
102
|
rules = set()
|
|
@@ -82,7 +105,8 @@ def inspect_text(data, path, config, resolver):
|
|
|
82
105
|
if any(pattern.search(text) for pattern in config['internalReferencePatterns']):
|
|
83
106
|
rules.add('hygiene.internal-reference')
|
|
84
107
|
if '@' in text:
|
|
85
|
-
|
|
108
|
+
email_text = decoded_text(without_public_coauthor_addresses(data)) if commit_message else text
|
|
109
|
+
emails = private_emails(email_text, config)
|
|
86
110
|
bundle = config.get('trustBundles', {}).get(path)
|
|
87
111
|
if emails and not (bundle and certificate_metadata(data, bundle, emails, config)):
|
|
88
112
|
# Inside binary data a mailbox is judged by its own shape: a host
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
{ "repository": "just-every/run-nook", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
21
21
|
{ "repository": "just-every/run-orbit", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
22
22
|
{ "repository": "just-every/run-oncall", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
23
|
-
{ "repository": "just-every/run-spool", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." }
|
|
23
|
+
{ "repository": "just-every/run-spool", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
24
|
+
{ "repository": "just-every/run-happytohelp", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." },
|
|
25
|
+
{ "repository": "just-every/run-auth", "reason": "Owner-proposed public name (13 Oct 2026), accepted ahead of the rename; created by clean-history export at the first public tag." }
|
|
24
26
|
]
|
|
25
27
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Digest fields in the formats emitted by edition-authoring cli/sources.mjs.
|
|
2
|
+
|
|
3
|
+
This recognizes metadata, not its freshness or integrity. Authoring's check
|
|
4
|
+
owns those validations; a retained, unselected archive is still metadata.
|
|
5
|
+
Only enumerated JSON value locations qualify, never arbitrary nested names.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
from json_identity import HEX, GIT_REVISION
|
|
9
|
+
import source_shapes
|
|
10
|
+
|
|
11
|
+
ARCHIVE = re.compile(r'\.edition/sources/([a-f0-9]{64})\.json\Z')
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fields(value, pointer, names, pattern=HEX):
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
for name in names:
|
|
17
|
+
if isinstance(value.get(name), str) and pattern.fullmatch(value[name]):
|
|
18
|
+
yield (*pointer, name)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pin(value, pointer):
|
|
22
|
+
# sources.mjs and cli.mjs emit this same three-digest reference shape.
|
|
23
|
+
if source_shapes.pin(value):
|
|
24
|
+
yield from fields(value, pointer, ('key', 'digest', 'sha256'))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def origin(value, pointer):
|
|
28
|
+
# snapshotOrigin in projection-lock.mjs copies one immutable identity.
|
|
29
|
+
if not source_shapes.reference(value):
|
|
30
|
+
return
|
|
31
|
+
if 'projection' not in value:
|
|
32
|
+
yield from fields(value, pointer, ('revision',), GIT_REVISION)
|
|
33
|
+
else:
|
|
34
|
+
yield from fields(value['projection'], (*pointer, 'projection'), ('originalSha256', 'recipeSha256'))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def source_pointers(path, value):
|
|
38
|
+
if not isinstance(value, dict):
|
|
39
|
+
return
|
|
40
|
+
if path == 'sources.lock.json':
|
|
41
|
+
if not source_shapes.lock(value):
|
|
42
|
+
return
|
|
43
|
+
yield from fields(value, (), ('manifest',))
|
|
44
|
+
yield from fields(value['toolchain'], ('toolchain',), ('sha256',))
|
|
45
|
+
yield from pin(value.get('parent'), ('parent',))
|
|
46
|
+
for index, component in enumerate(value['components']):
|
|
47
|
+
pointer = ('components', index)
|
|
48
|
+
yield from pin(component.get('baseline'), (*pointer, 'baseline'))
|
|
49
|
+
source = component['source']
|
|
50
|
+
if source['kind'] == 'git':
|
|
51
|
+
yield from origin(source, (*pointer, 'source'))
|
|
52
|
+
else:
|
|
53
|
+
yield from origin(source.get('origin'), (*pointer, 'source', 'origin'))
|
|
54
|
+
for name, entry in value['files'].items():
|
|
55
|
+
yield from fields(entry, ('files', name), ('sha256',))
|
|
56
|
+
elif ARCHIVE.fullmatch(path):
|
|
57
|
+
if not source_shapes.archive(value):
|
|
58
|
+
return
|
|
59
|
+
yield from fields(value, (), ('key', 'digest'))
|
|
60
|
+
yield from origin(value['origin'], ('origin',))
|
|
61
|
+
for index, dependency in enumerate(value.get('dependencies', [])):
|
|
62
|
+
yield from pin(dependency, ('dependencies', index))
|