feat: isolate privacy scanning and release signing
This commit is contained in:
11
containers/privacy-coordinator/Dockerfile
Normal file
11
containers/privacy-coordinator/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
LABEL org.opencontainers.image.title="portfolio-privacy-coordinator" \
|
||||
org.opencontainers.image.description="Trusted closure reconciliation without raw decoder authority"
|
||||
WORKDIR /opt/app
|
||||
COPY package.json package-lock.json /opt/app/
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
COPY scripts/release/reconcile-scan.mjs /opt/app/scripts/release/reconcile-scan.mjs
|
||||
COPY src/security /opt/app/src/security
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["node", "/opt/app/scripts/release/reconcile-scan.mjs", "--request", "/input/request.json"]
|
||||
8
containers/privacy-matcher/Dockerfile
Normal file
8
containers/privacy-matcher/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
LABEL org.opencontainers.image.title="portfolio-privacy-matcher" \
|
||||
org.opencontainers.image.description="Decoder-free independent privacy evidence matcher"
|
||||
WORKDIR /opt/matcher
|
||||
COPY containers/privacy-matcher/matcher.mjs /opt/matcher/matcher.mjs
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["node", "/opt/matcher/matcher.mjs"]
|
||||
49
containers/privacy-matcher/matcher.mjs
Normal file
49
containers/privacy-matcher/matcher.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
const request = JSON.parse(await readFile("/input/request.json", "utf8"));
|
||||
const [a, b] = request.reports ?? [];
|
||||
if (
|
||||
!a ||
|
||||
!b ||
|
||||
a.workerId !== "worker-a" ||
|
||||
b.workerId !== "worker-b" ||
|
||||
a.imageDigest === b.imageDigest ||
|
||||
a.toolchain === b.toolchain ||
|
||||
a.inputDigest !== b.inputDigest ||
|
||||
a.checkId !== b.checkId ||
|
||||
a.normalizedOutputDigest !== b.normalizedOutputDigest ||
|
||||
JSON.stringify(a.normalizedMembers) !== JSON.stringify(b.normalizedMembers) ||
|
||||
a.verdict !== "pass" ||
|
||||
b.verdict !== "pass"
|
||||
)
|
||||
throw new Error("Independent worker evidence does not agree.");
|
||||
const rules = request.rules;
|
||||
if (!Array.isArray(rules) || rules.length === 0)
|
||||
throw new Error("Matcher rules are required.");
|
||||
const appliedRuleIds = rules
|
||||
.map((rule) => rule.id)
|
||||
.sort((left, right) => Buffer.from(left).compare(Buffer.from(right)));
|
||||
if (
|
||||
new Set(appliedRuleIds).size !== appliedRuleIds.length ||
|
||||
rules.some((rule) => !Array.isArray(rule.deniedMemberDigests))
|
||||
)
|
||||
throw new Error("Matcher rules are invalid.");
|
||||
const denied = new Set(rules.flatMap((rule) => rule.deniedMemberDigests));
|
||||
if (a.normalizedMembers.some((member) => denied.has(member)))
|
||||
throw new Error("Privacy rule matched normalized evidence.");
|
||||
const result = {
|
||||
schema: "portfolio-privacy-matcher-result/v1",
|
||||
inputDigest: a.inputDigest,
|
||||
checkId: a.checkId,
|
||||
normalizedOutputDigest: a.normalizedOutputDigest,
|
||||
normalizedMembers: a.normalizedMembers,
|
||||
ruleSetDigest: request.ruleSetDigest,
|
||||
appliedRuleIds,
|
||||
verdict: "pass",
|
||||
workerImageDigests: [a.imageDigest, b.imageDigest],
|
||||
};
|
||||
await writeFile("/output/result.json", JSON.stringify(result), {
|
||||
flag: "wx",
|
||||
mode: 0o400,
|
||||
});
|
||||
8
containers/privacy-worker-a/Dockerfile
Normal file
8
containers/privacy-worker-a/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
LABEL org.opencontainers.image.title="portfolio-privacy-worker-a" \
|
||||
org.opencontainers.image.description="Independent byte-oriented Node privacy scanner"
|
||||
WORKDIR /opt/scanner
|
||||
COPY containers/privacy-worker-a/worker.mjs /opt/scanner/worker.mjs
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["node", "/opt/scanner/worker.mjs"]
|
||||
33
containers/privacy-worker-a/worker.mjs
Normal file
33
containers/privacy-worker-a/worker.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
const request = JSON.parse(await readFile("/input/request.json", "utf8"));
|
||||
if (
|
||||
request.schema !== "portfolio-privacy-worker-request/v1" ||
|
||||
request.workerId !== "worker-a" ||
|
||||
!/^[a-f0-9]{64}$/u.test(request.inputDigest)
|
||||
)
|
||||
throw new Error("Invalid worker request.");
|
||||
const bytes = await readFile("/input/object");
|
||||
const actual = createHash("sha256").update(bytes).digest("hex");
|
||||
if (actual !== request.inputDigest) throw new Error("Input digest mismatch.");
|
||||
const normalizedMembers = [`bytes:${actual}`];
|
||||
const normalizedOutputDigest = createHash("sha256")
|
||||
.update(Buffer.from(JSON.stringify(normalizedMembers)))
|
||||
.digest("hex");
|
||||
const output = {
|
||||
schema: "portfolio-privacy-worker-report/v1",
|
||||
workerId: "worker-a",
|
||||
imageDigest: request.imageDigest,
|
||||
toolchain: "bytes-js/v1",
|
||||
inputDigest: actual,
|
||||
checkId: request.checkId,
|
||||
normalizedOutputDigest,
|
||||
normalizedMembers,
|
||||
verdict: "pass",
|
||||
findingsDigest: "0".repeat(64),
|
||||
};
|
||||
await writeFile("/output/report.json", JSON.stringify(output), {
|
||||
flag: "wx",
|
||||
mode: 0o400,
|
||||
});
|
||||
8
containers/privacy-worker-b/Dockerfile
Normal file
8
containers/privacy-worker-b/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
LABEL org.opencontainers.image.title="portfolio-privacy-worker-b" \
|
||||
org.opencontainers.image.description="Independent byte-oriented Python privacy scanner"
|
||||
WORKDIR /opt/scanner
|
||||
COPY containers/privacy-worker-b/worker.py /opt/scanner/worker.py
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["python3", "/opt/scanner/worker.py"]
|
||||
20
containers/privacy-worker-b/worker.py
Normal file
20
containers/privacy-worker-b/worker.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
with open('/input/request.json', 'r', encoding='utf-8') as handle:
|
||||
request = json.load(handle)
|
||||
if request.get('schema') != 'portfolio-privacy-worker-request/v1' or request.get('workerId') != 'worker-b':
|
||||
raise ValueError('Invalid worker request.')
|
||||
with open('/input/object', 'rb') as handle:
|
||||
payload = handle.read()
|
||||
actual = hashlib.sha256(payload).hexdigest()
|
||||
if actual != request.get('inputDigest'):
|
||||
raise ValueError('Input digest mismatch.')
|
||||
members = ['bytes:' + actual]
|
||||
normalized = json.dumps(members, separators=(',', ':')).encode('utf-8')
|
||||
output = {'schema': 'portfolio-privacy-worker-report/v1', 'workerId': 'worker-b', 'imageDigest': request['imageDigest'],
|
||||
'toolchain': 'bytes-python/v1', 'inputDigest': actual, 'checkId': request['checkId'],
|
||||
'normalizedOutputDigest': hashlib.sha256(normalized).hexdigest(), 'normalizedMembers': members,
|
||||
'verdict': 'pass', 'findingsDigest': '0' * 64}
|
||||
with open('/output/report.json', 'x', encoding='utf-8') as handle:
|
||||
json.dump(output, handle, separators=(',', ':'))
|
||||
Reference in New Issue
Block a user