backend-skeleton 1.1.1 → 1.2.0
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 +27 -0
- package/bin/bskel.mjs +147 -14
- package/handles/providers/java-spring/observe.mjs +15 -1
- package/handles/providers/java-spring/templates/ContractObservationAspect.java.tmpl +38 -0
- package/handles/providers/java-spring/templates/ObserveSchemaLoader.java.tmpl +13 -7
- package/handles/providers/java-spring/templates/ReceiptSigner.java.tmpl +174 -0
- package/handles/providers/python-fastapi/observe.mjs +5 -0
- package/handles/providers/python-fastapi/templates/observe_contract.py.tmpl +19 -0
- package/handles/providers/python-fastapi/templates/receipt_sign.py.tmpl +68 -0
- package/handles/providers/typescript-express/observe.mjs +5 -0
- package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +19 -0
- package/handles/providers/typescript-express/templates/receiptSign.ts.tmpl +65 -0
- package/lib/cli.mjs +16 -1
- package/lib/field-dependencies.mjs +2 -1
- package/lib/gate-definitions.mjs +8 -4
- package/lib/scan-report-paths.mjs +47 -0
- package/package.json +1 -1
- package/scanners/adapters/python-fastapi.mjs +61 -2
- package/scanners/index.mjs +1 -1
- package/schemas/conformance-report.schema.json +12 -1
- package/schemas/observe-receipt.schema.json +10 -1
- package/schemas/scan-report.schema.json +1 -1
package/README.md
CHANGED
|
@@ -39,6 +39,7 @@ check for a specific failure mode found the same way — see `DECISIONS.md` for
|
|
|
39
39
|
- [Declaring field-to-field dependencies (optional)](#declaring-field-to-field-dependencies-optional)
|
|
40
40
|
- [Patching a config file (optional)](#patching-a-config-file-optional)
|
|
41
41
|
- [Signed gate attestations (optional)](#signed-gate-attestations-optional)
|
|
42
|
+
- [Signed observe receipts (optional)](#signed-observe-receipts-optional)
|
|
42
43
|
- [Compatibility](#compatibility)
|
|
43
44
|
- [Generated-file policy](#generated-file-policy)
|
|
44
45
|
- [Security model](#security-model)
|
|
@@ -344,6 +345,32 @@ bskel attest verify --file attestation.json --pubkey ~/.bskel-keys/attest-public
|
|
|
344
345
|
`attest verify`'s exit code reflects signature validity only — whether the gates inside actually
|
|
345
346
|
passed is a separate, printed summary. See `D-gate-attestation-signing` in `DECISIONS.md`.
|
|
346
347
|
|
|
348
|
+
### Signed observe receipts (optional)
|
|
349
|
+
|
|
350
|
+
`bskel observe emit` generates opt-in runtime middleware that checks real traffic against a
|
|
351
|
+
feature's contract and logs a verdict-only receipt per call (JSON Pointer + constraint kind, never
|
|
352
|
+
an observed value); `bskel observe import --receipts <path>` turns a stream of those receipts into
|
|
353
|
+
a committed report backing the `conformance` gate. By default a receipts file is trusted at face
|
|
354
|
+
value once it's structurally valid — a human could hand-fabricate one. Add `--pubkey <path>` to
|
|
355
|
+
`observe import` to verify each receipt's optional signature instead, reusing the same
|
|
356
|
+
`bskel attest keygen`-generated keypair signed gate attestations use:
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
bskel attest keygen --out ~/.bskel-keys # same command as above -- one keypair, multiple uses
|
|
360
|
+
# then, per deployed app (one-time, at the app's own startup):
|
|
361
|
+
# TypeScript: import { setSigningKey } from './observe/receiptSign'; setSigningKey(pem);
|
|
362
|
+
# Java: set the bskel.observe.signing-key-pem Spring property (e.g. an env var)
|
|
363
|
+
# Python: receipt_sign.configure(os.environ.get("BSKEL_OBSERVE_SIGNING_KEY_PEM"))
|
|
364
|
+
bskel observe import --feature 001-organization-management --receipts receipts.jsonl \
|
|
365
|
+
--pubkey ~/.bskel-keys/attest-public.pem [--require-signature]
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Unset/no key configured means every receipt stays unsigned — fully backward compatible with every
|
|
369
|
+
app already using this feature. `--pubkey` alone verifies signatures where present and tolerates
|
|
370
|
+
unsigned receipts (excluding them from the report's `matched` counts, with a printed warning);
|
|
371
|
+
`--require-signature` makes any unsigned or invalid receipt abort the whole import. See the
|
|
372
|
+
"cryptographic receipt attestation" update in `D-runtime-conformance-receipts` in `DECISIONS.md`.
|
|
373
|
+
|
|
347
374
|
Every command is read-only until you explicitly run one of the mutating steps above — `bskel
|
|
348
375
|
status`/`bskel next` (no arguments needed) tell you which gate is next and print the exact
|
|
349
376
|
copy-pasteable command for it, without touching anything.
|
package/bin/bskel.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { forceNamedGate, revokeNamedGate, requireNamedGate, passNamedGate, await
|
|
|
10
10
|
import { REPO_GATE_ID, GATE_NAMES, gateScopeId, requireGateDefinition } from '../lib/gate-definitions.mjs';
|
|
11
11
|
import { getGate, loadState, historyPath } from '../lib/state.mjs';
|
|
12
12
|
import { writeFileAtomic, sha256File, readJsonIfExists } from '../lib/fsutil.mjs';
|
|
13
|
+
import { hydrateScanReportFilePaths, dehydrateScanReportFilePaths } from '../lib/scan-report-paths.mjs';
|
|
13
14
|
import { validateAgainstSchema, formatSchemaErrors } from '../lib/schema-validate.mjs';
|
|
14
15
|
import { withLockSync } from '../lib/lock.mjs';
|
|
15
16
|
import { specDir, specPath, sbfPath } from '../lib/paths.mjs';
|
|
@@ -80,6 +81,7 @@ function usage() {
|
|
|
80
81
|
bskel scan [--feature <id>] [--terms a,b,c] [--json] [--accept-low-confidence] [--db [--database-url-env <NAME>] [--schema public]]
|
|
81
82
|
bskel scan disposition --feature <id> --mode reuse|extend|replace|parallel [--module <name>] [--note "..."] [--breaking-approved]
|
|
82
83
|
bskel scan explain <module> --feature <id> [--json]
|
|
84
|
+
bskel scan repair --feature <id> [--json]
|
|
83
85
|
bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]
|
|
84
86
|
bskel scan cross-feature-waive --feature <id> --signal resource_type|table|operation_id|db_foreign_key --identifier <name> --other-feature <id> --reason "..."
|
|
85
87
|
bskel feature init --slug <name>
|
|
@@ -109,7 +111,7 @@ function usage() {
|
|
|
109
111
|
bskel patch rollback --feature <id> --transaction <id> --reason "..." [--force] [--json]
|
|
110
112
|
bskel patch list --feature <id> [--json]
|
|
111
113
|
bskel observe emit --feature <id> [--module <name>] [--force --reason "..."] [--check] [--diff] [--json]
|
|
112
|
-
bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]
|
|
114
|
+
bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--pubkey <path> [--require-signature]] [--json]
|
|
113
115
|
bskel verify --feature <id> [--build [--allow-skip-build]] [--json]
|
|
114
116
|
bskel status [--feature <id>] [--json]
|
|
115
117
|
bskel next [--feature <id>] [--json]
|
|
@@ -668,7 +670,12 @@ async function cmdScan(args) {
|
|
|
668
670
|
|
|
669
671
|
const dir = specDir(root, flags.feature);
|
|
670
672
|
fs.mkdirSync(dir, { recursive: true });
|
|
671
|
-
|
|
673
|
+
// D-scan-report-portable-paths: `report`'s `.file` fields stay absolute in memory (every
|
|
674
|
+
// consumer's expected shape, including this same function's OWN JSON stdout print below) --
|
|
675
|
+
// only the ON-DISK copy is converted to repo-relative, so the committed artifact survives
|
|
676
|
+
// being checked out somewhere else (a second worktree, a different clone, CI) without
|
|
677
|
+
// requiring any change to how `.file` is read back in memory once re-hydrated.
|
|
678
|
+
writeScanReportOrExit(specPath(root, flags.feature, 'brownfield-scan.json'), dehydrateScanReportFilePaths(report, root));
|
|
672
679
|
writeFileAtomic(specPath(root, flags.feature, 'brownfield-scan.md'), renderScanMarkdown(report));
|
|
673
680
|
|
|
674
681
|
let gateState;
|
|
@@ -748,7 +755,7 @@ function cmdScanExplain(args) {
|
|
|
748
755
|
if (!moduleName) {
|
|
749
756
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'usage: bskel scan explain <module> --feature <id> [--json]');
|
|
750
757
|
}
|
|
751
|
-
const report =
|
|
758
|
+
const report = loadHydratedScanReportOrExit(root, flags.feature);
|
|
752
759
|
const mod = report.related_modules.find((m) => m.module === moduleName);
|
|
753
760
|
if (!mod) {
|
|
754
761
|
const known = report.related_modules.map((m) => m.module).join(', ') || '(none)';
|
|
@@ -762,6 +769,60 @@ function cmdScanExplain(args) {
|
|
|
762
769
|
process.exit(0);
|
|
763
770
|
}
|
|
764
771
|
|
|
772
|
+
// D-scan-report-portable-paths: a one-time, non-destructive migration for a committed
|
|
773
|
+
// brownfield-scan.json written BEFORE this fix (schema "sbf.scan-report/1", `.file` absolute) --
|
|
774
|
+
// `bskel scan`'s own re-run is NOT a substitute here: runScan() never carries `disposition`
|
|
775
|
+
// forward, so re-scanning would silently wipe an already-disposed feature's disposition and
|
|
776
|
+
// cascade `scan`/`contract`/`handles` gates back to stale/awaiting_disposition. This command
|
|
777
|
+
// touches ONLY `.file` strings and `schema` -- `disposition` and everything else stay
|
|
778
|
+
// byte-identical. Must run from the SAME location the original `bskel scan` ran from (a fresh
|
|
779
|
+
// worktree's absolute paths won't resolve to anything real) -- fails closed, naming the exact
|
|
780
|
+
// file, rather than guessing at a mapping.
|
|
781
|
+
function cmdScanRepair(args) {
|
|
782
|
+
const flags = parseCommand('scan repair', args);
|
|
783
|
+
if (flags.help) { console.log(renderCommandHelp('scan repair')); process.exit(0); }
|
|
784
|
+
setContext('scan repair', flags);
|
|
785
|
+
const root = requireRepoRoot();
|
|
786
|
+
requireValidFeatureId(flags.feature);
|
|
787
|
+
|
|
788
|
+
// Deliberately NOT loadScanReportOrExit() -- that validates against the CURRENT schema const
|
|
789
|
+
// ("sbf.scan-report/2"), which a genuinely old ("/1") report can never match by definition.
|
|
790
|
+
// This command's whole job is repairing exactly that mismatch, so it reads raw here.
|
|
791
|
+
const reportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
792
|
+
if (!fs.existsSync(reportPath)) {
|
|
793
|
+
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no scan report at ${reportPath} -- run \`bskel scan --feature ${flags.feature}\` first`);
|
|
794
|
+
}
|
|
795
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
796
|
+
if (report.schema !== 'sbf.scan-report/1') {
|
|
797
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `${flags.feature}'s scan report is already schema "${report.schema}" -- nothing to repair (this command only migrates a "sbf.scan-report/1" report's absolute \`.file\` paths to repo-relative).`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const unresolvable = [];
|
|
801
|
+
for (const mod of report.related_modules ?? []) {
|
|
802
|
+
for (const key of ['controllers', 'entities', 'enums', 'dtos']) {
|
|
803
|
+
for (const item of mod[key] ?? []) {
|
|
804
|
+
if (item.file && !fs.existsSync(item.file)) unresolvable.push(item.file);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (unresolvable.length > 0) {
|
|
809
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `refusing to guess: ${unresolvable.length} file(s) referenced in this scan report do not exist under the CURRENT root -- re-run this command from the exact location \`bskel scan\` originally ran from:\n${unresolvable.map((f) => ` ${f}`).join('\n')}`);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// D-scan-report-portable-paths: same transform `cmdScan` applies at every real write -- a
|
|
813
|
+
// legacy report is, by definition, exactly the "still absolute" case that needs converting.
|
|
814
|
+
const repaired = dehydrateScanReportFilePaths(report, root);
|
|
815
|
+
repaired.schema = 'sbf.scan-report/2';
|
|
816
|
+
writeScanReportOrExit(reportPath, repaired);
|
|
817
|
+
|
|
818
|
+
if (flags.json) {
|
|
819
|
+
console.log(JSON.stringify({ feature_id: flags.feature, schema: repaired.schema, repaired: true }, null, 2));
|
|
820
|
+
} else {
|
|
821
|
+
console.log(`repaired: ${flags.feature}'s scan report is now schema "${repaired.schema}" (disposition and every other field untouched)`);
|
|
822
|
+
}
|
|
823
|
+
process.exit(0);
|
|
824
|
+
}
|
|
825
|
+
|
|
765
826
|
// D-cross-feature-collision: mirrors cmdContractEmit's own "always write the artifact, gate
|
|
766
827
|
// blocks only if unresolved issues remain" shape exactly, for a different data source (NAME-
|
|
767
828
|
// identity collisions against every OTHER feature, not this feature's own contract completeness).
|
|
@@ -1194,11 +1255,11 @@ function cmdContractEmit(args) {
|
|
|
1194
1255
|
});
|
|
1195
1256
|
}
|
|
1196
1257
|
|
|
1258
|
+
// D-scan-report-portable-paths: was its own inline fs.existsSync/JSON.parse here, duplicating
|
|
1259
|
+
// (and bypassing) loadScanReportOrExit()'s schema validation -- consolidated onto the shared,
|
|
1260
|
+
// hydrated loader, closing both that gap and the portability bug the hydration itself fixes.
|
|
1197
1261
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
1198
|
-
|
|
1199
|
-
fail(EXIT_CODES.NOT_PASSED, 'MISSING_ARTIFACT', `no scan report at ${scanReportPath} -- run \`bskel scan --feature ${flags.feature}\` first`);
|
|
1200
|
-
}
|
|
1201
|
-
const scanReport = JSON.parse(fs.readFileSync(scanReportPath, 'utf8'));
|
|
1262
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
1202
1263
|
requireCapabilitiesOrExit(scanReport, 'contract emit', {
|
|
1203
1264
|
featureId: flags.feature,
|
|
1204
1265
|
scanReportPath,
|
|
@@ -2101,6 +2162,19 @@ function loadScanReportOrExit(root, featureId) {
|
|
|
2101
2162
|
return parsed;
|
|
2102
2163
|
}
|
|
2103
2164
|
|
|
2165
|
+
// D-scan-report-portable-paths: the read-only sibling of loadScanReportOrExit() -- every consumer
|
|
2166
|
+
// that only READS the report (never writes it back) should go through this instead, so its
|
|
2167
|
+
// `related_modules[].{controllers,entities,enums,dtos}[].file` values are correctly re-anchored to
|
|
2168
|
+
// THIS root before anything downstream (handles codegen, contract emission, gate recomputation)
|
|
2169
|
+
// touches them. Deliberately NOT folded into loadScanReportOrExit() itself: cmdScanDisposition()
|
|
2170
|
+
// does load -> mutate `.disposition` -> write back the WHOLE object -- if hydration lived in the
|
|
2171
|
+
// base loader, that round trip would silently re-persist re-absolutized paths to disk, resurrecting
|
|
2172
|
+
// the exact portability bug this closes. Any future read-modify-write command must stay on the raw
|
|
2173
|
+
// loader for the same reason.
|
|
2174
|
+
function loadHydratedScanReportOrExit(root, featureId) {
|
|
2175
|
+
return hydrateScanReportFilePaths(loadScanReportOrExit(root, featureId), root);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2104
2178
|
// S5 (D-persistence-integrity): the write-side sibling of loadScanReportOrExit() above -- validated
|
|
2105
2179
|
// before it ever touches disk, same "fail loud here, not as a confusing error somewhere later"
|
|
2106
2180
|
// reasoning as lib/state.mjs's saveState(). Used by both cmdScan()'s own write and
|
|
@@ -2225,7 +2299,7 @@ async function cmdHandlesPlan(args) {
|
|
|
2225
2299
|
if (flags.help) { console.log(renderCommandHelp('handles plan')); process.exit(0); }
|
|
2226
2300
|
setContext('handles plan', flags);
|
|
2227
2301
|
const root = requireRepoRoot();
|
|
2228
|
-
const scanReport =
|
|
2302
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2229
2303
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2230
2304
|
requireCapabilitiesOrExit(scanReport, 'handles plan', { featureId: flags.feature, scanReportPath });
|
|
2231
2305
|
const provider = selectProviderOrExit(scanReport);
|
|
@@ -2360,7 +2434,7 @@ function cmdHandlesEmit(args) {
|
|
|
2360
2434
|
});
|
|
2361
2435
|
}
|
|
2362
2436
|
|
|
2363
|
-
const scanReport =
|
|
2437
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2364
2438
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2365
2439
|
requireCapabilitiesOrExit(scanReport, 'handles emit', { featureId: flags.feature, scanReportPath });
|
|
2366
2440
|
const provider = selectProviderOrExit(scanReport);
|
|
@@ -2524,7 +2598,7 @@ function cmdHandlesPatchApprove(args) {
|
|
|
2524
2598
|
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', 'bskel handles patch approve requires --reason "..." -- every approval must be auditable');
|
|
2525
2599
|
}
|
|
2526
2600
|
|
|
2527
|
-
const scanReport =
|
|
2601
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2528
2602
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2529
2603
|
requireCapabilitiesOrExit(scanReport, 'handles patch approve', { featureId: flags.feature, scanReportPath });
|
|
2530
2604
|
const provider = selectProviderOrExit(scanReport);
|
|
@@ -2800,7 +2874,7 @@ async function cmdHandlesAudit(args) {
|
|
|
2800
2874
|
// live database rather than a static regex proxy.
|
|
2801
2875
|
let registryCoverage = null;
|
|
2802
2876
|
if (flags['check-registry-coverage']) {
|
|
2803
|
-
const scanReport =
|
|
2877
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2804
2878
|
const scanReportPath = specPath(root, flags.feature, 'brownfield-scan.json');
|
|
2805
2879
|
requireCapabilitiesOrExit(scanReport, 'handles audit --check-registry-coverage', { featureId: flags.feature, scanReportPath });
|
|
2806
2880
|
const provider = selectProviderOrExit(scanReport);
|
|
@@ -2875,7 +2949,7 @@ function cmdObserveEmit(args) {
|
|
|
2875
2949
|
});
|
|
2876
2950
|
}
|
|
2877
2951
|
|
|
2878
|
-
const scanReport =
|
|
2952
|
+
const scanReport = loadHydratedScanReportOrExit(root, flags.feature);
|
|
2879
2953
|
const contract = loadContract(root, flags.feature);
|
|
2880
2954
|
const dryRun = flags.check || flags.diff;
|
|
2881
2955
|
|
|
@@ -2993,6 +3067,20 @@ function cmdObserveImport(args) {
|
|
|
2993
3067
|
const flags = parseCommand('observe import', args);
|
|
2994
3068
|
if (flags.help) { console.log(renderCommandHelp('observe import')); process.exit(0); }
|
|
2995
3069
|
setContext('observe import', flags);
|
|
3070
|
+
if (flags['require-signature'] && !flags.pubkey) {
|
|
3071
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', '--require-signature was given but --pubkey was not -- refusing to require a signature this command has no way to check. Pass --pubkey <path>, or drop --require-signature to allow unsigned/unverified receipts (with a warning).');
|
|
3072
|
+
}
|
|
3073
|
+
// Mirrors `cmdAttestVerify`'s own pubkey-read pattern exactly. `null` (not given) means: don't
|
|
3074
|
+
// verify at all -- a signature field, if present on a receipt, is simply ignored, matching
|
|
3075
|
+
// today's behavior byte-for-byte (full backward compatibility with every already-deployed app).
|
|
3076
|
+
let pubkeyPem = null;
|
|
3077
|
+
if (flags.pubkey) {
|
|
3078
|
+
try {
|
|
3079
|
+
pubkeyPem = fs.readFileSync(path.resolve(process.cwd(), flags.pubkey), 'utf8');
|
|
3080
|
+
} catch (err) {
|
|
3081
|
+
fail(EXIT_CODES.BAD_ARGS, 'BAD_ARGS', `could not read --pubkey "${flags.pubkey}": ${err.message}`);
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
2996
3084
|
const root = requireRepoRoot();
|
|
2997
3085
|
requirePreflightPassed(root);
|
|
2998
3086
|
|
|
@@ -3044,12 +3132,50 @@ function cmdObserveImport(args) {
|
|
|
3044
3132
|
}
|
|
3045
3133
|
|
|
3046
3134
|
const currentContractHash = sha256File(specPath(root, flags.feature, 'contracts', `${flags.feature}.schema.json`));
|
|
3135
|
+
|
|
3136
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): verification always
|
|
3137
|
+
// happens here, in Node, regardless of which language's runtime produced+signed the receipt --
|
|
3138
|
+
// lib/attest.mjs's verifyPayload() is reused completely unmodified (already payload-shape-
|
|
3139
|
+
// agnostic, the same reuse D-ddl-apply's own maybeSignStep() already established for a
|
|
3140
|
+
// different payload shape). `unsigned` is computed regardless of --pubkey (forward visibility);
|
|
3141
|
+
// `signature_invalid` is only ever non-zero when --pubkey was given -- there is no key to judge
|
|
3142
|
+
// a signature against otherwise. Without --pubkey, every receipt is trusted exactly like today
|
|
3143
|
+
// (a signature field, if present, is never even looked at) -- full backward compatibility.
|
|
3144
|
+
let unsignedCount = 0;
|
|
3145
|
+
let signatureInvalidCount = 0;
|
|
3146
|
+
const trustedByIndex = receipts.map((r) => {
|
|
3147
|
+
const hasSignature = Boolean(r.signature);
|
|
3148
|
+
if (!hasSignature) unsignedCount++;
|
|
3149
|
+
if (!pubkeyPem) return true;
|
|
3150
|
+
if (!hasSignature) return false;
|
|
3151
|
+
const { signature, ...unsigned } = r;
|
|
3152
|
+
const valid = verifyPayload(unsigned, signature.value, pubkeyPem);
|
|
3153
|
+
if (!valid) signatureInvalidCount++;
|
|
3154
|
+
return valid;
|
|
3155
|
+
});
|
|
3156
|
+
if (flags['require-signature']) {
|
|
3157
|
+
const badIndex = trustedByIndex.findIndex((trusted) => !trusted);
|
|
3158
|
+
if (badIndex !== -1) {
|
|
3159
|
+
const bad = receipts[badIndex];
|
|
3160
|
+
const reason = bad.signature ? 'its signature does not verify against --pubkey' : 'it has no signature at all';
|
|
3161
|
+
fail(EXIT_CODES.NOT_PASSED, 'INVALID_ARTIFACT', `${flags.receipts}: a receipt for operation "${bad.operation_id}" is untrusted -- ${reason}. Aborting the whole import (--require-signature demands every receipt verify, and a corrupted/untrusted receipts file must not partially land).`);
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
if (pubkeyPem && unsignedCount > 0 && !flags.quiet) {
|
|
3165
|
+
console.error(`WARNING: ${unsignedCount} receipt(s) have no signature -- excluded from matched/violation counts now that --pubkey is checking signatures. Pass --require-signature to make this a hard failure instead.`);
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
// Untrusted receipts (unsigned or signature-invalid, only possible when --pubkey was given)
|
|
3169
|
+
// are excluded from EVERY count below -- not "noise" (non-JSON garbage) and not "corruption"
|
|
3170
|
+
// (schema-invalid, aborts the whole import), a genuinely new third tier alongside
|
|
3171
|
+
// stale_contract_ref's own existing "kept on record, excluded from current evidence" precedent.
|
|
3047
3172
|
let matched = 0;
|
|
3048
3173
|
let staleContractRef = 0;
|
|
3049
3174
|
let violationCount = 0;
|
|
3050
3175
|
let unsupportedCount = 0;
|
|
3051
3176
|
const byOperation = {};
|
|
3052
|
-
|
|
3177
|
+
receipts.forEach((r, i) => {
|
|
3178
|
+
if (!trustedByIndex[i]) return;
|
|
3053
3179
|
const isMatched = r.contract_ref === currentContractHash;
|
|
3054
3180
|
if (isMatched) matched++; else staleContractRef++;
|
|
3055
3181
|
const opStats = byOperation[r.operation_id] ?? { matched: 0, stale_contract_ref: 0, violations: 0 };
|
|
@@ -3059,7 +3185,7 @@ function cmdObserveImport(args) {
|
|
|
3059
3185
|
if (isMatched) opStats.violations++;
|
|
3060
3186
|
}
|
|
3061
3187
|
byOperation[r.operation_id] = opStats;
|
|
3062
|
-
}
|
|
3188
|
+
});
|
|
3063
3189
|
|
|
3064
3190
|
const report = {
|
|
3065
3191
|
sbf_conformance_report: '1',
|
|
@@ -3073,8 +3199,11 @@ function cmdObserveImport(args) {
|
|
|
3073
3199
|
matched, stale_contract_ref: staleContractRef,
|
|
3074
3200
|
violations: violationCount,
|
|
3075
3201
|
unsupported: unsupportedCount,
|
|
3202
|
+
unsigned: unsignedCount,
|
|
3203
|
+
signature_invalid: signatureInvalidCount,
|
|
3076
3204
|
},
|
|
3077
3205
|
by_operation: byOperation,
|
|
3206
|
+
verification: { pubkey_given: Boolean(pubkeyPem), require_signature: Boolean(flags['require-signature']) },
|
|
3078
3207
|
};
|
|
3079
3208
|
const { ok: reportOk, errors: reportErrors } = validateAgainstSchema('conformance-report.schema.json', report);
|
|
3080
3209
|
if (!reportOk) {
|
|
@@ -3103,6 +3232,9 @@ function cmdObserveImport(args) {
|
|
|
3103
3232
|
console.log(JSON.stringify({ report, noise_lines: noiseLines, gate: gateState.gates.conformance }, null, 2));
|
|
3104
3233
|
} else {
|
|
3105
3234
|
console.log(`imported ${receipts.length} receipt(s) (${matched} matched the current contract, ${staleContractRef} stale, ${noiseLines} noise line(s) skipped)`);
|
|
3235
|
+
if (pubkeyPem) {
|
|
3236
|
+
console.log(`signatures: ${unsignedCount} unsigned, ${signatureInvalidCount} invalid (both excluded from the counts above)`);
|
|
3237
|
+
}
|
|
3106
3238
|
console.log(`${violationCount} violation(s), ${unsupportedCount} unsupported field(s) across matched receipts`);
|
|
3107
3239
|
console.log(`wrote ${path.relative(root, reportPath)}`);
|
|
3108
3240
|
console.log(`gate: conformance -> ${gateState.gates.conformance.status}`);
|
|
@@ -3625,6 +3757,7 @@ async function dispatchCommand(cmd, rest) {
|
|
|
3625
3757
|
case 'scan': {
|
|
3626
3758
|
if (rest[0] === 'disposition') return cmdScanDisposition(rest.slice(1));
|
|
3627
3759
|
if (rest[0] === 'explain') return cmdScanExplain(rest.slice(1));
|
|
3760
|
+
if (rest[0] === 'repair') return cmdScanRepair(rest.slice(1));
|
|
3628
3761
|
if (rest[0] === 'cross-feature-check') return cmdScanCrossFeatureCheck(rest.slice(1));
|
|
3629
3762
|
if (rest[0] === 'cross-feature-waive') return cmdScanCrossFeatureWaive(rest.slice(1));
|
|
3630
3763
|
await cmdScan(rest);
|
|
@@ -24,6 +24,10 @@ const INFRA_FILES = [
|
|
|
24
24
|
{ template: 'ContractCheck.java.tmpl', target: 'global/observe/ContractCheck.java' },
|
|
25
25
|
{ template: 'ObserveSchemaLoader.java.tmpl', target: 'global/observe/ObserveSchemaLoader.java' },
|
|
26
26
|
{ template: 'ContractObservationAspect.java.tmpl', target: 'global/observe/ContractObservationAspect.java' },
|
|
27
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): JDK-stdlib-only Ed25519
|
|
28
|
+
// signer, used by ContractObservationAspect -- see that template's own javadoc for why this is
|
|
29
|
+
// a hand-rolled canonicalizer, not a Jackson mapper feature.
|
|
30
|
+
{ template: 'ReceiptSigner.java.tmpl', target: 'global/observe/ReceiptSigner.java' },
|
|
27
31
|
];
|
|
28
32
|
|
|
29
33
|
function render(templatePath, vars) {
|
|
@@ -51,12 +55,21 @@ function writeUnit(target, content) {
|
|
|
51
55
|
export function emitObserveJavaSpring({ repoRoot, featureId, contract, basePackage, force = false, reason = '', dryRun = false, computeDiff = false }) {
|
|
52
56
|
const javaSrcRoot = path.join(repoRoot, 'src', 'main', 'java', ...basePackage.split('.'));
|
|
53
57
|
const jacksonPackage = detectJacksonPackage(repoRoot);
|
|
58
|
+
// D-runtime-conformance-receipts (Jackson 2/3 JsonNode field-iteration parity): Jackson 3's
|
|
59
|
+
// JsonNode has no #fields()/#fieldNames() at all (confirmed live via javap against real
|
|
60
|
+
// jackson-databind 3.1.5 -- only #properties(), a Set); Jackson 2's #fields() exists across
|
|
61
|
+
// every real version checked (2.14 through 2.21), but #properties() does NOT exist on the
|
|
62
|
+
// older ones (2.14 lacks it, 2.17+ has it) -- so the safe, version-spanning choice per major is
|
|
63
|
+
// #fields() for Jackson 2, #properties() for Jackson 3, never the other way around.
|
|
64
|
+
const jacksonFieldsOfImpl = jacksonPackage === 'tools.jackson.databind'
|
|
65
|
+
? 'return node.properties();'
|
|
66
|
+
: 'return () -> node.fields();';
|
|
54
67
|
|
|
55
68
|
const infraUnits = INFRA_FILES.map((f) => ({
|
|
56
69
|
id: f.template,
|
|
57
70
|
templatePath: path.join(TEMPLATES_DIR, f.template),
|
|
58
71
|
targetAbs: path.join(javaSrcRoot, f.target),
|
|
59
|
-
rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage }),
|
|
72
|
+
rendered: render(path.join(TEMPLATES_DIR, f.template), { BASE_PACKAGE: basePackage, JACKSON_PACKAGE: jacksonPackage, JACKSON_FIELDS_OF_IMPL: jacksonFieldsOfImpl }),
|
|
60
73
|
}));
|
|
61
74
|
|
|
62
75
|
const result = emitUnits({ repoRoot, featureId, provider: 'java-spring', force, reason, infraUnits, resolverUnits: [], orphanScan: null, dryRun, computeDiff });
|
|
@@ -89,6 +102,7 @@ export function emitObserveJavaSpring({ repoRoot, featureId, contract, basePacka
|
|
|
89
102
|
'NOT done automatically: route the "bskel.observe.receipts" SLF4J logger to wherever you want receipt lines collected (a dedicated logback/log4j2 appender to a file, your existing log pipeline, etc.) -- bskel never edits your logging config. Point `bskel observe import --receipts <path>` at whatever that logger\'s output ends up as.',
|
|
90
103
|
`Contract-conformance checking only covers path params always, plus a bounded slice of request/response/error body shape -- and only when this contract was emitted with --openapi-file. See the emitted ${path.relative(repoRoot, schemaPath)}'s own "unsupported" markers for exactly what is skipped for this feature.`,
|
|
91
104
|
'NOT done automatically: apply @ObserveContract(operationId = "...") to whichever existing controller/service methods you want observed -- nothing is annotated for you (D-resolver-scope: never guess which method implements which operation).',
|
|
105
|
+
'NOT done automatically: to sign receipts, set the `bskel.observe.signing-key-pem` Spring property (e.g. an env var via Spring\'s own relaxed binding: BSKEL_OBSERVE_SIGNING_KEY_PEM=...) to a PKCS#8 Ed25519 private key PEM -- `bskel attest keygen --out <dir>` already generates one in this exact format. Unset means every receipt stays unsigned (backward compatible). Verify with `bskel observe import --pubkey <path/to/attest-public.pem>`.',
|
|
92
106
|
],
|
|
93
107
|
};
|
|
94
108
|
}
|
|
@@ -5,11 +5,13 @@ import {{JACKSON_PACKAGE}}.ObjectMapper;
|
|
|
5
5
|
import {{JACKSON_PACKAGE}}.node.ArrayNode;
|
|
6
6
|
import {{JACKSON_PACKAGE}}.node.ObjectNode;
|
|
7
7
|
import {{BASE_PACKAGE}}.global.observe.ObserveSchemaLoader.ObservedOperation;
|
|
8
|
+
import jakarta.annotation.PostConstruct;
|
|
8
9
|
import lombok.RequiredArgsConstructor;
|
|
9
10
|
import lombok.extern.slf4j.Slf4j;
|
|
10
11
|
import org.aspectj.lang.ProceedingJoinPoint;
|
|
11
12
|
import org.aspectj.lang.annotation.Around;
|
|
12
13
|
import org.aspectj.lang.annotation.Aspect;
|
|
14
|
+
import org.springframework.beans.factory.annotation.Value;
|
|
13
15
|
import org.springframework.http.ResponseEntity;
|
|
14
16
|
import org.springframework.stereotype.Component;
|
|
15
17
|
import org.springframework.web.bind.annotation.RequestBody;
|
|
@@ -49,6 +51,10 @@ import java.util.Map;
|
|
|
49
51
|
* deliberately deferred, same unexamined assumption {@code HandleAspect} already carries for
|
|
50
52
|
* {@code @Around} advice generally. See DECISIONS.md D-runtime-conformance-receipts.
|
|
51
53
|
*
|
|
54
|
+
* <p>Optionally signs each receipt (Ed25519, via {@link ReceiptSigner}) when {@code
|
|
55
|
+
* bskel.observe.signing-key-pem} is set -- see that class's own javadoc. Unset means every
|
|
56
|
+
* receipt stays unsigned, exactly like before this capability existed.
|
|
57
|
+
*
|
|
52
58
|
* <p>Generated by backend-skeleton ({@code bskel observe emit}). Requires {@code
|
|
53
59
|
* spring-boot-starter-aop} on the classpath -- see {@link ObserveContract}'s own javadoc.
|
|
54
60
|
*/
|
|
@@ -63,6 +69,21 @@ public class ContractObservationAspect {
|
|
|
63
69
|
private final ObserveSchemaLoader schemaLoader;
|
|
64
70
|
private final ObjectMapper objectMapper;
|
|
65
71
|
|
|
72
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): a plain, non-final,
|
|
73
|
+
// separately field-injected member -- NOT routed through the @RequiredArgsConstructor-generated
|
|
74
|
+
// constructor above. @Value on a `final` constructor-injected field alongside
|
|
75
|
+
// @RequiredArgsConstructor is a documented Lombok/Spring interop gap (the generated constructor
|
|
76
|
+
// does not reliably propagate the annotation); this is the standard, real-world-safe pattern for
|
|
77
|
+
// @Value-injected simple config even in classes that otherwise use constructor injection.
|
|
78
|
+
// Empty (the default) means every receipt stays unsigned -- see ReceiptSigner's own javadoc.
|
|
79
|
+
@Value("${bskel.observe.signing-key-pem:}")
|
|
80
|
+
private String signingKeyPem;
|
|
81
|
+
|
|
82
|
+
@PostConstruct
|
|
83
|
+
private void configureReceiptSigning() {
|
|
84
|
+
ReceiptSigner.configure(signingKeyPem);
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
@Around("@annotation(observeContract)")
|
|
67
88
|
public Object observe(ProceedingJoinPoint joinPoint, ObserveContract observeContract) throws Throwable {
|
|
68
89
|
String operationId = observeContract.operationId();
|
|
@@ -159,9 +180,26 @@ public class ContractObservationAspect {
|
|
|
159
180
|
vn.put("keyword", v.keyword());
|
|
160
181
|
vn.put("message", v.message());
|
|
161
182
|
}
|
|
183
|
+
safelySign(receipt);
|
|
162
184
|
safelyLog(receipt);
|
|
163
185
|
}
|
|
164
186
|
|
|
187
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): its OWN inner try/catch,
|
|
188
|
+
// separate from safelyLog()'s -- a signing failure (bad/missing key config, malformed PEM) must
|
|
189
|
+
// fall back to logging the receipt UNSIGNED, not silently drop the whole receipt the way sharing
|
|
190
|
+
// safelyLog()'s catch block would.
|
|
191
|
+
private void safelySign(ObjectNode receipt) {
|
|
192
|
+
if (!ReceiptSigner.isConfigured()) return;
|
|
193
|
+
try {
|
|
194
|
+
String signatureValue = ReceiptSigner.sign(receipt);
|
|
195
|
+
ObjectNode signature = receipt.putObject("signature");
|
|
196
|
+
signature.put("algorithm", "ed25519");
|
|
197
|
+
signature.put("value", signatureValue);
|
|
198
|
+
} catch (Exception e) {
|
|
199
|
+
log.warn("ContractObservationAspect: could not sign a receipt -- logging it unsigned instead", e);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
165
203
|
private void safelyLog(Object receipt) {
|
|
166
204
|
try {
|
|
167
205
|
RECEIPTS.info(objectMapper.writeValueAsString(receipt));
|
|
@@ -11,7 +11,6 @@ import java.io.IOException;
|
|
|
11
11
|
import java.io.InputStream;
|
|
12
12
|
import java.util.ArrayList;
|
|
13
13
|
import java.util.HashMap;
|
|
14
|
-
import java.util.Iterator;
|
|
15
14
|
import java.util.List;
|
|
16
15
|
import java.util.Map;
|
|
17
16
|
import java.util.regex.Pattern;
|
|
@@ -69,9 +68,7 @@ public class ObserveSchemaLoader {
|
|
|
69
68
|
String featureId = root.path("feature_id").asText(null);
|
|
70
69
|
String featureUid = root.path("feature_uid").asText(null);
|
|
71
70
|
String contractRef = root.path("contract_ref").asText(null);
|
|
72
|
-
|
|
73
|
-
while (ops.hasNext()) {
|
|
74
|
-
Map.Entry<String, JsonNode> entry = ops.next();
|
|
71
|
+
for (Map.Entry<String, JsonNode> entry : fieldsOf(root.path("operations"))) {
|
|
75
72
|
String operationId = entry.getKey();
|
|
76
73
|
JsonNode op = entry.getValue();
|
|
77
74
|
String body = op.path("body").asText("unknown");
|
|
@@ -99,9 +96,7 @@ public class ObserveSchemaLoader {
|
|
|
99
96
|
List<String> required = parseStringList(node.path("required"));
|
|
100
97
|
List<String> unsupported = new ArrayList<>(parseStringList(node.path("unsupported")));
|
|
101
98
|
Map<String, ObservedProperty> properties = new HashMap<>();
|
|
102
|
-
|
|
103
|
-
while (props.hasNext()) {
|
|
104
|
-
Map.Entry<String, JsonNode> entry = props.next();
|
|
99
|
+
for (Map.Entry<String, JsonNode> entry : fieldsOf(node.path("properties"))) {
|
|
105
100
|
JsonNode propSchema = entry.getValue();
|
|
106
101
|
String type = propSchema.path("type").asText(null);
|
|
107
102
|
String patternText = propSchema.has("pattern") ? propSchema.get("pattern").asText() : null;
|
|
@@ -119,6 +114,17 @@ public class ObserveSchemaLoader {
|
|
|
119
114
|
return new ObservedObject(required, properties, unsupported);
|
|
120
115
|
}
|
|
121
116
|
|
|
117
|
+
// D-runtime-conformance-receipts (Jackson 2/3 JsonNode field-iteration parity): Jackson 2's
|
|
118
|
+
// JsonNode#fields() (an Iterator) does not exist on Jackson 3's JsonNode at all -- Jackson 3
|
|
119
|
+
// replaced it with #properties() (a Set, no Iterator wrapper needed). Neither API exists on
|
|
120
|
+
// EVERY version of the other major (older Jackson 2.x, e.g. 2.14, has no #properties() either),
|
|
121
|
+
// so this can't be unified into one call safely -- {{JACKSON_PACKAGE}} already tells us which
|
|
122
|
+
// major is on this target's real classpath (detectJacksonPackage() in emit.mjs), so that same
|
|
123
|
+
// signal picks the one real, correct implementation body at emit time.
|
|
124
|
+
private static Iterable<Map.Entry<String, JsonNode>> fieldsOf(JsonNode node) {
|
|
125
|
+
{{JACKSON_FIELDS_OF_IMPL}}
|
|
126
|
+
}
|
|
127
|
+
|
|
122
128
|
private static List<String> parseStringList(JsonNode node) {
|
|
123
129
|
List<String> values = new ArrayList<>();
|
|
124
130
|
if (node != null && node.isArray()) {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
package {{BASE_PACKAGE}}.global.observe;
|
|
2
|
+
|
|
3
|
+
import {{JACKSON_PACKAGE}}.JsonNode;
|
|
4
|
+
import {{JACKSON_PACKAGE}}.node.ObjectNode;
|
|
5
|
+
|
|
6
|
+
import java.nio.charset.StandardCharsets;
|
|
7
|
+
import java.security.KeyFactory;
|
|
8
|
+
import java.security.PrivateKey;
|
|
9
|
+
import java.security.Signature;
|
|
10
|
+
import java.security.spec.PKCS8EncodedKeySpec;
|
|
11
|
+
import java.util.ArrayList;
|
|
12
|
+
import java.util.Base64;
|
|
13
|
+
import java.util.Collections;
|
|
14
|
+
import java.util.List;
|
|
15
|
+
import java.util.Map;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* D-runtime-conformance-receipts (cryptographic receipt attestation): signs a receipt at emission
|
|
19
|
+
* time so {@code bskel observe import --pubkey <path>} can prove it genuinely came from this
|
|
20
|
+
* running app, not a hand-fabricated file. JDK-stdlib-only ({@link Signature}, Ed25519 -- standard
|
|
21
|
+
* since JDK 15) -- zero new dependency, matching this project's own JDK 17 floor.
|
|
22
|
+
*
|
|
23
|
+
* <p>Canonicalization must be byte-identical to {@code bskel observe import}'s own verification
|
|
24
|
+
* side (Node's {@code lib/attest.mjs} {@code canonicalize()}: deep-sorted keys, compact JSON,
|
|
25
|
+
* non-ASCII left unescaped) -- proven cross-language-compatible by direct execution against a real
|
|
26
|
+
* receipt containing a forward slash, a quote, and non-ASCII text (see DECISIONS.md
|
|
27
|
+
* D-runtime-conformance-receipts). Jackson's own {@code MapperFeature.SORT_PROPERTIES_ALPHABETICALLY}
|
|
28
|
+
* does NOT sort an {@link ObjectNode}'s own tree-model field order -- confirmed live, it only
|
|
29
|
+
* affects bean/POJO introspection -- so this class hand-walks the {@link JsonNode} tree instead of
|
|
30
|
+
* relying on a mapper feature that would silently no-op.
|
|
31
|
+
*
|
|
32
|
+
* <p>The private key is never baked into generated source (that would commit a secret to the
|
|
33
|
+
* repo) -- see {@link #configure(String)}'s own javadoc for how a human wires this in.
|
|
34
|
+
*/
|
|
35
|
+
public final class ReceiptSigner {
|
|
36
|
+
|
|
37
|
+
private static volatile PrivateKey privateKey;
|
|
38
|
+
|
|
39
|
+
private ReceiptSigner() {
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* NOT called by any generated code -- a human calls this once, themselves, at application
|
|
44
|
+
* startup (see {@code ContractObservationAspect}'s own {@code @PostConstruct} wiring), passing a
|
|
45
|
+
* PKCS#8 PEM private key string -- the exact format {@code bskel attest keygen} already writes.
|
|
46
|
+
* Never hardcode a real key value in source; read it from wherever you already keep secrets (an
|
|
47
|
+
* env var, a mounted file, a secrets manager). Unconfigured (the default) means every receipt
|
|
48
|
+
* stays unsigned -- backward compatible with every already-deployed app using this feature
|
|
49
|
+
* before signing existed.
|
|
50
|
+
*/
|
|
51
|
+
public static void configure(String privateKeyPem) {
|
|
52
|
+
privateKey = (privateKeyPem == null || privateKeyPem.isBlank()) ? null : parsePkcs8(privateKeyPem);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static boolean isConfigured() {
|
|
56
|
+
return privateKey != null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Returns the base64 Ed25519 signature over the canonicalized receipt. Callers must pass a node
|
|
61
|
+
* that does not yet carry a {@code "signature"} field (this class never strips one itself --
|
|
62
|
+
* {@code ContractObservationAspect} only ever calls this before adding one).
|
|
63
|
+
*/
|
|
64
|
+
static String sign(ObjectNode receiptWithoutSignature) {
|
|
65
|
+
try {
|
|
66
|
+
Signature signer = Signature.getInstance("Ed25519");
|
|
67
|
+
signer.initSign(privateKey);
|
|
68
|
+
signer.update(canonicalize(receiptWithoutSignature).getBytes(StandardCharsets.UTF_8));
|
|
69
|
+
return Base64.getEncoder().encodeToString(signer.sign());
|
|
70
|
+
} catch (Exception e) {
|
|
71
|
+
throw new IllegalStateException("could not sign receipt", e);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private static PrivateKey parsePkcs8(String pem) {
|
|
76
|
+
try {
|
|
77
|
+
String base64 = pem
|
|
78
|
+
.replace("-----BEGIN PRIVATE KEY-----", "")
|
|
79
|
+
.replace("-----END PRIVATE KEY-----", "")
|
|
80
|
+
.replaceAll("\\s", "");
|
|
81
|
+
byte[] der = Base64.getDecoder().decode(base64);
|
|
82
|
+
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
|
|
83
|
+
return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der));
|
|
84
|
+
} catch (Exception e) {
|
|
85
|
+
throw new IllegalArgumentException("not a usable PKCS#8 Ed25519 private key PEM", e);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Recursively sorts every object's field names, preserves array order (matching lib/gates.mjs's
|
|
90
|
+
// own sortKeysDeep() semantics exactly on the Node verification side), emits compact JSON by
|
|
91
|
+
// hand. The schema this signs has exactly one numeric field (a plain integer HTTP status) and
|
|
92
|
+
// nothing else numeric anywhere -- no float-formatting cross-language risk exists here.
|
|
93
|
+
static String canonicalize(JsonNode node) {
|
|
94
|
+
StringBuilder sb = new StringBuilder();
|
|
95
|
+
canonicalizeInto(node, sb);
|
|
96
|
+
return sb.toString();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private static void canonicalizeInto(JsonNode node, StringBuilder sb) {
|
|
100
|
+
if (node == null || node.isNull()) {
|
|
101
|
+
sb.append("null");
|
|
102
|
+
} else if (node.isObject()) {
|
|
103
|
+
List<String> names = new ArrayList<>();
|
|
104
|
+
for (Map.Entry<String, JsonNode> entry : fieldsOf(node)) {
|
|
105
|
+
names.add(entry.getKey());
|
|
106
|
+
}
|
|
107
|
+
Collections.sort(names);
|
|
108
|
+
sb.append('{');
|
|
109
|
+
for (int i = 0; i < names.size(); i++) {
|
|
110
|
+
if (i > 0) sb.append(',');
|
|
111
|
+
sb.append(jsonString(names.get(i))).append(':');
|
|
112
|
+
canonicalizeInto(node.get(names.get(i)), sb);
|
|
113
|
+
}
|
|
114
|
+
sb.append('}');
|
|
115
|
+
} else if (node.isArray()) {
|
|
116
|
+
sb.append('[');
|
|
117
|
+
for (int i = 0; i < node.size(); i++) {
|
|
118
|
+
if (i > 0) sb.append(',');
|
|
119
|
+
canonicalizeInto(node.get(i), sb);
|
|
120
|
+
}
|
|
121
|
+
sb.append(']');
|
|
122
|
+
} else if (node.isTextual()) {
|
|
123
|
+
sb.append(jsonString(node.asText()));
|
|
124
|
+
} else if (node.isBoolean()) {
|
|
125
|
+
sb.append(node.asBoolean());
|
|
126
|
+
} else if (node.isNumber()) {
|
|
127
|
+
sb.append(node.numberValue());
|
|
128
|
+
} else {
|
|
129
|
+
sb.append(jsonString(node.asText()));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// D-runtime-conformance-receipts (Jackson 2/3 JsonNode field-iteration parity): Jackson 2's
|
|
134
|
+
// JsonNode#fields() (an Iterator) does not exist on Jackson 3's JsonNode at all -- Jackson 3
|
|
135
|
+
// replaced it with #properties() (a Set, no Iterator wrapper needed). Neither API exists on
|
|
136
|
+
// EVERY version of the other major (older Jackson 2.x, e.g. 2.14, has no #properties() either),
|
|
137
|
+
// so this can't be unified into one call safely -- {{JACKSON_PACKAGE}} already tells us which
|
|
138
|
+
// major is on this target's real classpath (detectJacksonPackage() in emit.mjs), so that same
|
|
139
|
+
// signal picks the one real, correct implementation body at emit time. Mirrors
|
|
140
|
+
// ObserveSchemaLoader's own identically-named, identically-reasoned helper exactly.
|
|
141
|
+
private static Iterable<Map.Entry<String, JsonNode>> fieldsOf(JsonNode node) {
|
|
142
|
+
{{JACKSON_FIELDS_OF_IMPL}}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Standard JSON string escaping (RFC 8259) -- the same set Node's JSON.stringify and Python's
|
|
146
|
+
// json.dumps(ensure_ascii=False) both already apply, proven byte-identical live. Non-ASCII is
|
|
147
|
+
// deliberately left UNESCAPED to match (ensure_ascii=False on the Python side is the analogous,
|
|
148
|
+
// load-bearing requirement there -- Python's own json.dumps default would otherwise diverge).
|
|
149
|
+
private static String jsonString(String s) {
|
|
150
|
+
StringBuilder sb = new StringBuilder(s.length() + 2);
|
|
151
|
+
sb.append('"');
|
|
152
|
+
for (int i = 0; i < s.length(); i++) {
|
|
153
|
+
char c = s.charAt(i);
|
|
154
|
+
switch (c) {
|
|
155
|
+
case '"' -> sb.append("\\\"");
|
|
156
|
+
case '\\' -> sb.append("\\\\");
|
|
157
|
+
case '\b' -> sb.append("\\b");
|
|
158
|
+
case '\f' -> sb.append("\\f");
|
|
159
|
+
case '\n' -> sb.append("\\n");
|
|
160
|
+
case '\r' -> sb.append("\\r");
|
|
161
|
+
case '\t' -> sb.append("\\t");
|
|
162
|
+
default -> {
|
|
163
|
+
if (c < 0x20) {
|
|
164
|
+
sb.append(String.format("\\u%04x", (int) c));
|
|
165
|
+
} else {
|
|
166
|
+
sb.append(c);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
sb.append('"');
|
|
172
|
+
return sb.toString();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -26,6 +26,10 @@ const INFRA_FILES = [
|
|
|
26
26
|
{ template: 'observed_schema.py.tmpl', target: 'observed_schema.py' },
|
|
27
27
|
{ template: 'contract_check.py.tmpl', target: 'contract_check.py' },
|
|
28
28
|
{ template: 'observe_contract.py.tmpl', target: 'observe_contract.py' },
|
|
29
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): Ed25519 signer used by
|
|
30
|
+
// observe_contract.py -- imports the `cryptography` package lazily, only if actually configured
|
|
31
|
+
// with a real key (see receipt_sign.py's own docstring for why).
|
|
32
|
+
{ template: 'receipt_sign.py.tmpl', target: 'receipt_sign.py' },
|
|
29
33
|
];
|
|
30
34
|
|
|
31
35
|
function render(templatePath, vars) {
|
|
@@ -92,6 +96,7 @@ export function emitObservePythonFastApi({ repoRoot, featureId, contract, plan,
|
|
|
92
96
|
'NOT done automatically: route the "bskel.observe.receipts" logger (Python\'s standard logging module) to wherever you want receipt lines collected (a dedicated handler to a file, your existing log pipeline, etc.) -- bskel never edits your logging config. Point `bskel observe import --receipts <path>` at whatever that logger\'s output ends up as.',
|
|
93
97
|
`Contract-conformance checking only covers path params always, plus a bounded slice of request/response/error body shape -- and only when this contract was emitted with --openapi-file. See the emitted ${path.relative(repoRoot, schemaPath)}'s own "unsupported" markers for exactly what is skipped for this feature.`,
|
|
94
98
|
'NOT done automatically: apply @observe_contract(operation_id="...") to whichever existing route handlers you want observed -- nothing is decorated for you (D-resolver-scope: never guess which function implements which operation). For a request body to be checked, also pass body_param="<the argument name>" explicitly -- Python has no @RequestBody-equivalent marker to infer it from.',
|
|
99
|
+
'NOT done automatically: to sign receipts, call receipt_sign.configure(os.environ.get("BSKEL_OBSERVE_SIGNING_KEY_PEM")) yourself at application startup, with a PKCS#8 Ed25519 private key PEM -- `bskel attest keygen --out <dir>` already generates one in this exact format. This also requires `pip install cryptography` (Python\'s stdlib has no Ed25519 signing -- receipt_sign.py imports it lazily, only when configure() is actually called with a real key). Unconfigured means every receipt stays unsigned (backward compatible). Verify with `bskel observe import --pubkey <path/to/attest-public.pem>`.',
|
|
95
100
|
],
|
|
96
101
|
};
|
|
97
102
|
}
|
|
@@ -32,6 +32,10 @@ return value transparently, unmodified, even without inspecting it) -- FastAPI r
|
|
|
32
32
|
commonly `async def`, so getting this wrong breaks production traffic, not just the conformance
|
|
33
33
|
check.
|
|
34
34
|
|
|
35
|
+
Optionally signs each receipt (Ed25519, via receipt_sign.py) once `receipt_sign.configure(...)`
|
|
36
|
+
has been called with a real key -- see that module's own docstring. Unconfigured means every
|
|
37
|
+
receipt stays unsigned, exactly like before this capability existed.
|
|
38
|
+
|
|
35
39
|
Example:
|
|
36
40
|
@observe_contract(operation_id="items-read_item")
|
|
37
41
|
async def read_item(session: SessionDep, id: str):
|
|
@@ -48,6 +52,7 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
|
48
52
|
|
|
49
53
|
from . import contract_check
|
|
50
54
|
from . import observed_schema
|
|
55
|
+
from . import receipt_sign
|
|
51
56
|
|
|
52
57
|
logger = logging.getLogger(__name__)
|
|
53
58
|
_RECEIPTS = logging.getLogger("bskel.observe.receipts")
|
|
@@ -102,11 +107,25 @@ def _emit_receipt(op: dict, operation_id: str, request_violations: list, respons
|
|
|
102
107
|
receipt["status"] = status
|
|
103
108
|
if error_class is not None:
|
|
104
109
|
receipt["error_class"] = error_class
|
|
110
|
+
_sign_receipt(receipt)
|
|
105
111
|
_RECEIPTS.info(json.dumps(receipt))
|
|
106
112
|
except Exception:
|
|
107
113
|
logger.warning("observe_contract: could not emit a receipt -- the wrapped call already returned unaffected", exc_info=True)
|
|
108
114
|
|
|
109
115
|
|
|
116
|
+
def _sign_receipt(receipt: dict) -> None:
|
|
117
|
+
# D-runtime-conformance-receipts (cryptographic receipt attestation): its OWN inner try/except,
|
|
118
|
+
# separate from _emit_receipt()'s outer one -- a signing failure (bad/missing key config,
|
|
119
|
+
# malformed PEM) must fall back to logging the receipt UNSIGNED, not silently drop the whole
|
|
120
|
+
# receipt the way sharing the outer except block would.
|
|
121
|
+
if not receipt_sign.is_configured():
|
|
122
|
+
return
|
|
123
|
+
try:
|
|
124
|
+
receipt["signature"] = {"algorithm": "ed25519", "value": receipt_sign.sign(receipt)}
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.warning("observe_contract: could not sign a receipt -- logging it unsigned instead", exc_info=True)
|
|
127
|
+
|
|
128
|
+
|
|
110
129
|
def observe_contract(*, operation_id: str, body_param: str | None = None):
|
|
111
130
|
def decorator(fn):
|
|
112
131
|
signature = inspect.signature(fn)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Generated by backend-skeleton (bskel observe emit). Do not hand-edit -- change the source
|
|
2
|
+
template and regenerate.
|
|
3
|
+
|
|
4
|
+
D-runtime-conformance-receipts (cryptographic receipt attestation): signs a receipt at emission
|
|
5
|
+
time so `bskel observe import --pubkey <path>` can prove it genuinely came from this running app,
|
|
6
|
+
not a hand-fabricated file. Uses the `cryptography` package's Ed25519 primitives -- Python's
|
|
7
|
+
stdlib has no Ed25519 signing at all, so this is a genuinely new, first-of-its-kind third-party
|
|
8
|
+
dependency for this provider's generated runtime code (same honest framing this project already
|
|
9
|
+
gave `pg`, its own first-ever database dependency -- see DECISIONS.md
|
|
10
|
+
D-runtime-conformance-receipts). The import is deliberately LAZY (inside configure(), not at
|
|
11
|
+
module level) so this module -- and observe_contract.py, which imports it unconditionally --
|
|
12
|
+
stays importable even when `cryptography` is not installed, as long as signing is never
|
|
13
|
+
configured. Only pip-install `cryptography` if you actually call configure() with a real key.
|
|
14
|
+
|
|
15
|
+
Canonicalization must be byte-identical to `bskel observe import`'s own verification side (Node's
|
|
16
|
+
`lib/attest.mjs` canonicalize(): deep-sorted keys, compact JSON, non-ASCII left unescaped) --
|
|
17
|
+
`json.dumps(receipt, sort_keys=True, separators=(",", ":"), ensure_ascii=False)` is exactly that,
|
|
18
|
+
proven cross-language-compatible by direct execution against a real receipt containing a forward
|
|
19
|
+
slash, a quote, and non-ASCII text (see DECISIONS.md D-runtime-conformance-receipts).
|
|
20
|
+
`ensure_ascii=False` is load-bearing -- Python's own json.dumps default (True) escapes non-ASCII
|
|
21
|
+
as `\\uXXXX`, which neither Node's JSON.stringify nor Jackson's default writer do; without this
|
|
22
|
+
flag, signatures would never cross-verify on any receipt whose message contains non-ASCII text.
|
|
23
|
+
|
|
24
|
+
The private key is never baked into generated source (that would commit a secret to the repo) --
|
|
25
|
+
see configure()'s own docstring for how a human wires this in.
|
|
26
|
+
"""
|
|
27
|
+
import base64
|
|
28
|
+
import json
|
|
29
|
+
|
|
30
|
+
_private_key = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def configure(private_key_pem):
|
|
34
|
+
"""NOT called by any generated code -- a human calls this once, themselves, at application
|
|
35
|
+
startup (e.g. `receipt_sign.configure(os.environ.get("BSKEL_OBSERVE_SIGNING_KEY_PEM"))`),
|
|
36
|
+
passing a PKCS#8 PEM private key string -- the exact format `bskel attest keygen` already
|
|
37
|
+
writes. Never hardcode a real key value in source; read it from wherever you already keep
|
|
38
|
+
secrets (an env var, a mounted file, a secrets manager). Unconfigured (the default, including
|
|
39
|
+
a None/blank string) means every receipt stays unsigned -- backward compatible with every
|
|
40
|
+
already-deployed app using this feature before signing existed.
|
|
41
|
+
"""
|
|
42
|
+
global _private_key
|
|
43
|
+
if not private_key_pem or not private_key_pem.strip():
|
|
44
|
+
_private_key = None
|
|
45
|
+
return
|
|
46
|
+
from cryptography.hazmat.primitives import serialization
|
|
47
|
+
|
|
48
|
+
_private_key = serialization.load_pem_private_key(private_key_pem.encode("utf-8"), password=None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_configured():
|
|
52
|
+
return _private_key is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def canonicalize(receipt_without_signature):
|
|
56
|
+
# The schema this signs has exactly one numeric field (a plain integer HTTP status) and
|
|
57
|
+
# nothing else numeric anywhere -- no float-formatting cross-language risk exists here.
|
|
58
|
+
return json.dumps(
|
|
59
|
+
receipt_without_signature, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
60
|
+
).encode("utf-8")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sign(receipt_without_signature):
|
|
64
|
+
"""Returns the base64 Ed25519 signature over the canonicalized receipt. Callers must pass a
|
|
65
|
+
dict that does not yet carry a "signature" key -- this module never strips one itself.
|
|
66
|
+
"""
|
|
67
|
+
signature_bytes = _private_key.sign(canonicalize(receipt_without_signature))
|
|
68
|
+
return base64.b64encode(signature_bytes).decode("ascii")
|
|
@@ -28,6 +28,10 @@ const INFRA_FILES = [
|
|
|
28
28
|
{ template: 'contractCheck.ts.tmpl', target: 'contractCheck.ts' },
|
|
29
29
|
{ template: 'observedSchema.ts.tmpl', target: 'observedSchema.ts' },
|
|
30
30
|
{ template: 'observeContract.ts.tmpl', target: 'observeContract.ts' },
|
|
31
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): node:crypto-only Ed25519
|
|
32
|
+
// signer used by observeContract.ts -- a self-contained port of lib/attest.mjs, never imported
|
|
33
|
+
// directly (this runs inside a deployed target app, a foreign process from this CLI's own).
|
|
34
|
+
{ template: 'receiptSign.ts.tmpl', target: 'receiptSign.ts' },
|
|
31
35
|
];
|
|
32
36
|
|
|
33
37
|
function render(templatePath, vars) {
|
|
@@ -97,6 +101,7 @@ export function emitObserveTypeScriptExpress({ repoRoot, featureId, contract, pl
|
|
|
97
101
|
'error_class is never populated in this provider\'s receipts (always omitted) -- Express middleware runs BEFORE the route handler and is structurally unable to observe a thrown error the way java\'s @Around/python\'s except block can (by the time a handler throws or calls next(err), this middleware\'s own call frame has already returned). See DECISIONS.md D-runtime-conformance-receipts.',
|
|
98
102
|
'Response-body checking only covers a handler that calls res.json(...) or res.send(<object>) (Express\'s own res.send delegates to res.json for a plain-object body) -- a handler that calls res.send(<string>)/res.end(...) directly, or whose response is produced by Express\'s own default/generic error handler, has its response check silently skipped, never guessed.',
|
|
99
103
|
'OpenAPI reconciliation for this adapter matches scanned Express route strings EXACTLY against the OpenAPI document\'s own path keys (contracts/openapi.mjs has no ":id" <-> "{id}" translation) -- a real, standards-compliant OpenAPI document (which must use "{id}") will not match a scanned ":id"/":id([0-9]+)" route unless the document\'s own path key happens to already read that way. Unlike python-fastapi, this is not "for free."',
|
|
104
|
+
'NOT done automatically: to sign receipts, call setSigningKey(pem) yourself at application startup (import { setSigningKey } from \'./observe/receiptSign\';), with a PKCS#8 Ed25519 private key PEM -- `bskel attest keygen --out <dir>` already generates one in this exact format. Unconfigured means every receipt stays unsigned (backward compatible). Verify with `bskel observe import --pubkey <path/to/attest-public.pem>`.',
|
|
100
105
|
],
|
|
101
106
|
};
|
|
102
107
|
}
|
|
@@ -34,12 +34,17 @@
|
|
|
34
34
|
// (req.body is Express's own unambiguous body once body-parsing middleware has run -- no
|
|
35
35
|
// python-style explicit body_param argument needed here).
|
|
36
36
|
//
|
|
37
|
+
// Optionally signs each receipt (Ed25519, via receiptSign.ts) once setSigningKey() has been
|
|
38
|
+
// called with a real key -- see that module's own doc comment. Unconfigured means every
|
|
39
|
+
// receipt stays unsigned, exactly like before this capability existed.
|
|
40
|
+
//
|
|
37
41
|
// Example:
|
|
38
42
|
// router.get('/users/:id', [checkJwt, observeContract('users-show')], showUser);
|
|
39
43
|
|
|
40
44
|
import type { RequestHandler, Request, Response } from 'express';
|
|
41
45
|
import * as contractCheck from './contractCheck';
|
|
42
46
|
import * as observedSchema from './observedSchema';
|
|
47
|
+
import * as receiptSign from './receiptSign';
|
|
43
48
|
import type { ObservedOperation } from './observedSchema';
|
|
44
49
|
import type { Violation } from './contractCheck';
|
|
45
50
|
|
|
@@ -90,12 +95,26 @@ function emitReceipt(op: ObservedOperation, operationId: string, requestViolatio
|
|
|
90
95
|
recorded_at: new Date().toISOString(),
|
|
91
96
|
violations: allViolations,
|
|
92
97
|
};
|
|
98
|
+
signReceipt(receipt);
|
|
93
99
|
receiptSink(JSON.stringify(receipt));
|
|
94
100
|
} catch (err) {
|
|
95
101
|
console.warn(`observeContract: could not emit a receipt for "${operationId}"`, err);
|
|
96
102
|
}
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): its OWN inner try/catch,
|
|
106
|
+
// separate from emitReceipt()'s outer one -- a signing failure (bad/missing key config, malformed
|
|
107
|
+
// PEM) must fall back to sending the receipt UNSIGNED, not silently drop the whole receipt the way
|
|
108
|
+
// sharing the outer catch would.
|
|
109
|
+
function signReceipt(receipt: Record<string, unknown>): void {
|
|
110
|
+
if (!receiptSign.isConfigured()) return;
|
|
111
|
+
try {
|
|
112
|
+
receipt.signature = { algorithm: 'ed25519', value: receiptSign.sign(receipt) };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn('observeContract: could not sign a receipt -- sending it unsigned instead', err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
export function observeContract(operationId: string): RequestHandler {
|
|
100
119
|
return (req: Request, res: Response, next) => {
|
|
101
120
|
let op: ObservedOperation | undefined;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Generated by backend-skeleton (bskel observe emit). Do not hand-edit -- change the source
|
|
2
|
+
// template and regenerate.
|
|
3
|
+
//
|
|
4
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): signs a receipt at emission
|
|
5
|
+
// time so `bskel observe import --pubkey <path>` can prove it genuinely came from this running
|
|
6
|
+
// app, not a hand-fabricated file. A self-contained PORT of lib/attest.mjs's own canonicalize()/
|
|
7
|
+
// signPayload() -- never imported directly. This file runs inside a DEPLOYED target app, a
|
|
8
|
+
// foreign process from this CLI's own even though both happen to be Node -- same "port, don't
|
|
9
|
+
// cross-import into generated code" rule codec.ts.tmpl already established for handles/codec.mjs.
|
|
10
|
+
// node:crypto only, zero new dependency, matching this project's own "no new dependency where the
|
|
11
|
+
// runtime already provides the primitive" discipline (the same reasoning java-spring's own
|
|
12
|
+
// ReceiptSigner.java uses java.security.Signature instead of a Bouncy Castle dependency).
|
|
13
|
+
//
|
|
14
|
+
// Canonicalization must be byte-identical to `bskel observe import`'s own verification side (Node's
|
|
15
|
+
// lib/attest.mjs canonicalize()) -- proven cross-language-compatible by direct execution against a
|
|
16
|
+
// real receipt containing a forward slash, a quote, and non-ASCII text (see DECISIONS.md
|
|
17
|
+
// D-runtime-conformance-receipts). Deep-sorted object keys, array order preserved, compact JSON,
|
|
18
|
+
// non-ASCII left unescaped -- exactly Node's own JSON.stringify default behavior once keys are
|
|
19
|
+
// pre-sorted, so no special-casing is needed here the way Python's ensure_ascii=False is.
|
|
20
|
+
|
|
21
|
+
import { sign as cryptoSign } from 'node:crypto';
|
|
22
|
+
|
|
23
|
+
let privateKeyPem: string | null = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* NOT called by any generated code -- a human calls this once, themselves, at application
|
|
27
|
+
* startup, passing a PKCS#8 PEM private key string -- the exact format `bskel attest keygen`
|
|
28
|
+
* already writes. Never hardcode a real key value in source; read it from wherever you already
|
|
29
|
+
* keep secrets (an env var, a mounted file, a secrets manager). Unconfigured (the default,
|
|
30
|
+
* including null/blank) means every receipt stays unsigned -- backward compatible with every
|
|
31
|
+
* already-deployed app using this feature before signing existed.
|
|
32
|
+
*/
|
|
33
|
+
export function setSigningKey(pem: string | null | undefined): void {
|
|
34
|
+
privateKeyPem = pem && pem.trim().length > 0 ? pem : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isConfigured(): boolean {
|
|
38
|
+
return privateKeyPem !== null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sortKeysDeep(value: unknown): unknown {
|
|
42
|
+
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
|
43
|
+
if (value !== null && typeof value === 'object') {
|
|
44
|
+
const sorted: Record<string, unknown> = {};
|
|
45
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
46
|
+
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
|
|
47
|
+
}
|
|
48
|
+
return sorted;
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function canonicalize(value: unknown): string {
|
|
54
|
+
return JSON.stringify(sortKeysDeep(value));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns the base64 Ed25519 signature over the canonicalized receipt. Callers must pass an
|
|
59
|
+
* object that does not yet carry a "signature" key -- this module never strips one itself.
|
|
60
|
+
*/
|
|
61
|
+
export function sign(receiptWithoutSignature: unknown): string {
|
|
62
|
+
if (!privateKeyPem) throw new Error('receiptSign.sign() called before setSigningKey()');
|
|
63
|
+
const canonical = canonicalize(receiptWithoutSignature);
|
|
64
|
+
return cryptoSign(null, Buffer.from(canonical), privateKeyPem).toString('base64');
|
|
65
|
+
}
|
package/lib/cli.mjs
CHANGED
|
@@ -148,6 +148,13 @@ export const COMMANDS = {
|
|
|
148
148
|
},
|
|
149
149
|
allowPositionals: true,
|
|
150
150
|
},
|
|
151
|
+
'scan repair': {
|
|
152
|
+
usage: 'bskel scan repair --feature <id> [--json]',
|
|
153
|
+
options: {
|
|
154
|
+
feature: { type: 'string', default: null, required: true },
|
|
155
|
+
json: { type: 'boolean', default: false },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
151
158
|
'scan cross-feature-check': {
|
|
152
159
|
usage: 'bskel scan cross-feature-check --feature <id> [--db [--database-url-env <NAME>] [--schema public]] [--json]',
|
|
153
160
|
options: {
|
|
@@ -394,12 +401,20 @@ export const COMMANDS = {
|
|
|
394
401
|
json: { type: 'boolean', default: false },
|
|
395
402
|
},
|
|
396
403
|
},
|
|
404
|
+
// D-runtime-conformance-receipts (cryptographic receipt attestation): --pubkey alone has real
|
|
405
|
+
// standalone meaning (verify signatures where present, tolerate unsigned receipts) -- mirrors
|
|
406
|
+
// `serve`'s own `--sign-key` (opt-in signing, no `--require-sign-key` needed), NOT `gate export
|
|
407
|
+
// --sign`/`--key`'s mutual-requirement (a bare `--key` there is meaningless without `--sign`).
|
|
408
|
+
// --require-signature without --pubkey is refused -- mirrors `serve`'s own "mandatory signing,
|
|
409
|
+
// opt-in-to-more-strictness" `--require-sign-key`-without-`--sign-key` precedent exactly.
|
|
397
410
|
'observe import': {
|
|
398
|
-
usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--json]',
|
|
411
|
+
usage: 'bskel observe import --feature <id> --receipts <path> [--fail-on-violation] [--pubkey <path> [--require-signature]] [--json]',
|
|
399
412
|
options: {
|
|
400
413
|
feature: { type: 'string', default: null, required: true },
|
|
401
414
|
receipts: { type: 'string', default: null, required: true },
|
|
402
415
|
'fail-on-violation': { type: 'boolean', default: false },
|
|
416
|
+
pubkey: { type: 'string', default: null },
|
|
417
|
+
'require-signature': { type: 'boolean', default: false },
|
|
403
418
|
json: { type: 'boolean', default: false },
|
|
404
419
|
},
|
|
405
420
|
},
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// that gets passed and the token later required can never diverge.
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { readJsonIfExists, writeFileAtomic } from './fsutil.mjs';
|
|
14
|
+
import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
|
|
14
15
|
import { specPath } from './paths.mjs';
|
|
15
16
|
import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
|
|
16
17
|
import { listFeatures, loadFeatureFile } from './featurelifecycle.mjs';
|
|
@@ -119,7 +120,7 @@ export function dependencyKey(dep) {
|
|
|
119
120
|
// slice doesn't address.
|
|
120
121
|
export function resolveClassFile(root, featureId, resourceType) {
|
|
121
122
|
const reportPath = specPath(root, featureId, 'brownfield-scan.json');
|
|
122
|
-
const report = readJsonIfExists(reportPath);
|
|
123
|
+
const report = hydrateScanReportFilePaths(readJsonIfExists(reportPath), root);
|
|
123
124
|
if (!report) return { file: null, reason: 'no_scan_report' };
|
|
124
125
|
const moduleName = report.disposition?.module ?? report.related_modules?.[0]?.module;
|
|
125
126
|
if (!moduleName) return { file: null, reason: 'no_disposition' };
|
package/lib/gate-definitions.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { specPath, sbfPath } from './paths.mjs';
|
|
|
23
23
|
import { ADAPTERS, adapterById } from '../scanners/registry.mjs';
|
|
24
24
|
import { loadManifest } from './handles-manifest.mjs';
|
|
25
25
|
import { dependenciesPath, resolveClassFile } from './field-dependencies.mjs';
|
|
26
|
+
import { hydrateScanReportFilePaths } from './scan-report-paths.mjs';
|
|
26
27
|
import { crossFeatureReportPath, crossFeatureResolutionPath } from './cross-feature-collisions.mjs';
|
|
27
28
|
import { listTransactions, transactionPath } from './patch-transactions.mjs';
|
|
28
29
|
|
|
@@ -243,7 +244,7 @@ export const GATE_DEFINITIONS = Object.freeze({
|
|
|
243
244
|
verifyPolicy: VERIFY_POLICY.REQUIRED,
|
|
244
245
|
recompute: (root, featureId) => {
|
|
245
246
|
const reportPath = specPath(root, featureId, 'brownfield-scan.json');
|
|
246
|
-
const report = readJsonIfExists(reportPath);
|
|
247
|
+
const report = hydrateScanReportFilePaths(readJsonIfExists(reportPath), root);
|
|
247
248
|
const inputs = {
|
|
248
249
|
scan_report_hash: sha256File(reportPath),
|
|
249
250
|
contract_hash: sha256File(specPath(root, featureId, 'contracts', `${featureId}.schema.json`)),
|
|
@@ -257,9 +258,12 @@ export const GATE_DEFINITIONS = Object.freeze({
|
|
|
257
258
|
// (part 3)" in DECISIONS.md.
|
|
258
259
|
for (const item of [...(mod?.controllers ?? []), ...(mod?.entities ?? []), ...(mod?.enums ?? []), ...(mod?.dtos ?? [])]) {
|
|
259
260
|
if (!item.file) continue;
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
261
|
+
// D-scan-report-portable-paths: `.file` is repo-relative on disk (adapters write
|
|
262
|
+
// it that way now); `hydrateScanReportFilePaths()` above already re-anchored it to
|
|
263
|
+
// THIS root, so a plain path.relative(root, item.file) is correct regardless of
|
|
264
|
+
// where `bskel scan` originally ran -- no longer sensitive to a stale baked-in
|
|
265
|
+
// absolute path from a different worktree/clone. See DECISIONS.md for the real
|
|
266
|
+
// bug this closes (found via a real second-worktree pilot re-verification).
|
|
263
267
|
const rel = path.relative(root, item.file);
|
|
264
268
|
inputs[`${MODULE_FILE_PREFIX}${rel}`] = sha256File(item.file);
|
|
265
269
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// D-scan-report-portable-paths: every scanner adapter builds `related_modules[].{controllers,
|
|
2
|
+
// entities,enums,dtos}[].file` ABSOLUTE (this is deliberate, unchanged -- it is the shape every
|
|
3
|
+
// in-memory/direct-API consumer has always expected and still does: `runScan()` called directly
|
|
4
|
+
// and handed straight to `provider.plan()`/`planHandles()` is a real, widely-used pattern across
|
|
5
|
+
// this project's own test suite, not just the CLI). The ONLY place an absolute `.file` is a real
|
|
6
|
+
// problem is once it gets COMMITTED to git as part of `specs/<feature>/brownfield-scan.json`
|
|
7
|
+
// (confirmed NOT gitignored for real feature work) -- a value baked in from wherever `bskel scan`
|
|
8
|
+
// originally ran is meaningless once that same committed branch is checked out somewhere else (a
|
|
9
|
+
// second worktree, a different clone, CI). So the fix lives at exactly the disk-persistence
|
|
10
|
+
// boundary, not in the adapters or in every downstream consumer:
|
|
11
|
+
// - `dehydrateScanReportFilePaths()` converts absolute -> repo-relative, called ONCE, right
|
|
12
|
+
// before `bin/bskel.mjs`'s `cmdScan` writes the report to disk (mirrors the adapters' own
|
|
13
|
+
// pre-existing `filesRead` convention -- one `path.relative(repoRoot, f)` call, just applied
|
|
14
|
+
// at the write boundary instead of duplicated across 4 adapters).
|
|
15
|
+
// - `hydrateScanReportFilePaths()` converts back, called at every point something RE-LOADS the
|
|
16
|
+
// on-disk report (`loadHydratedScanReportOrExit`, the `contract` gate, `resolveClassFile`) --
|
|
17
|
+
// `path.isAbsolute(item.file)` doubles as a legacy-shape guard, so a report committed BEFORE
|
|
18
|
+
// this fix (already absolute on disk) passes through as a correct no-op rather than a bug;
|
|
19
|
+
// `bskel scan repair` is the real remedy for a stale-but-still-absolute value from a moved
|
|
20
|
+
// worktree, not this function.
|
|
21
|
+
//
|
|
22
|
+
// Both functions return a NEW object (via structuredClone), never mutate their input -- `cmdScan`
|
|
23
|
+
// needs the SAME in-memory `report` for both the disk write (dehydrated) and the `--json` stdout
|
|
24
|
+
// print (left absolute, untouched) from ONE scan; mutating in place would make whichever happened
|
|
25
|
+
// first corrupt the other.
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
function mapScanReportFiles(report, transform) {
|
|
29
|
+
if (!report) return report;
|
|
30
|
+
const next = structuredClone(report);
|
|
31
|
+
for (const mod of next.related_modules ?? []) {
|
|
32
|
+
for (const key of ['controllers', 'entities', 'enums', 'dtos']) {
|
|
33
|
+
for (const item of mod[key] ?? []) {
|
|
34
|
+
if (item.file) item.file = transform(item.file);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function hydrateScanReportFilePaths(report, repoRoot) {
|
|
42
|
+
return mapScanReportFiles(report, (file) => (path.isAbsolute(file) ? file : path.join(repoRoot, file)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function dehydrateScanReportFilePaths(report, repoRoot) {
|
|
46
|
+
return mapScanReportFiles(report, (file) => (path.isAbsolute(file) ? path.relative(repoRoot, file) : file));
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-skeleton",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -25,6 +25,12 @@ const FASTAPI_DEP_RE = /(?:^|[\s"'[])fastapi(?:\[[^\]]*\])?(?:[\s"',\]=<>~!;]|$)
|
|
|
25
25
|
|
|
26
26
|
const VERB_DECORATOR_RE = /@(\w+)\.(get|post|put|patch|delete)\s*\(/gi;
|
|
27
27
|
const ROUTER_DECL_RE = /(\w+)\s*=\s*APIRouter\s*\(/g;
|
|
28
|
+
|
|
29
|
+
// D-fastapi-generic-module-name: real evidence only (polarsource/polar), not a speculative list --
|
|
30
|
+
// same "validated against a real oracle" precedent as KNOWN_DTO_SUFFIXES below. 57 real files
|
|
31
|
+
// literally named `endpoints.py`, 1 named `router.py`; no other generic stem (`routes`, `views`,
|
|
32
|
+
// `api`) has ever been observed.
|
|
33
|
+
const GENERIC_ROUTER_STEMS = new Set(['endpoints', 'router']);
|
|
28
34
|
const CLASS_RE = /^class\s+(\w+)\s*\(([^)]*)\)\s*:/gm;
|
|
29
35
|
const INCLUDE_ROUTER_RE = /include_router\s*\(/g;
|
|
30
36
|
|
|
@@ -287,8 +293,59 @@ const API_SURFACE_SOURCE = 'router-local paths only (this scan does not resolve
|
|
|
287
293
|
'document via `bskel contract emit --openapi-file <path> --path-prefix <prefix>` for trustworthy ' +
|
|
288
294
|
'operation identity and schemas.';
|
|
289
295
|
|
|
296
|
+
// D-fastapi-generic-module-name: real polarsource/polar dogfooding found `path.basename(file,
|
|
297
|
+
// '.py')` (the module-name rule below) collapses to the literal string "endpoints"/"router" for
|
|
298
|
+
// every file using that repo's own real, common convention -- name every router file generically,
|
|
299
|
+
// carry the real domain identity in the PARENT DIRECTORY instead (`organization/endpoints.py`,
|
|
300
|
+
// `member/endpoints.py`). Two CLOSED passes, not interleaved -- a generic file's directory-derived
|
|
301
|
+
// candidate must never be adopted before every non-generic file's own literal name is known, or a
|
|
302
|
+
// later-discovered collision could arrive too late (the wrong merge would already be written).
|
|
303
|
+
// Pass 1: every non-generic-stem file's own literal name is `reserved`, unchanged from today.
|
|
304
|
+
// Pass 2: a generic-stem file's candidate (its own parent directory's basename) is adopted only if
|
|
305
|
+
// it collides with neither `reserved` nor another generic file's already-`claimed` candidate --
|
|
306
|
+
// real evidence found 9 such real collisions in polar alone (e.g. `subscription/endpoints.py`'s
|
|
307
|
+
// candidate "subscription" collides with the real, unrelated, non-generic
|
|
308
|
+
// `customer_portal/endpoints/subscription.py`). On any collision, the file keeps its old literal
|
|
309
|
+
// generic name -- never guess into a silent wrong merge. `files` is already sorted by full path
|
|
310
|
+
// (`listRgFiles`), so processing order (and therefore first-claim-wins) is deterministic.
|
|
311
|
+
function resolveGenericModuleNames(files, readFile, projectRoot) {
|
|
312
|
+
const routerFiles = [];
|
|
313
|
+
const reserved = new Set();
|
|
314
|
+
for (const file of files) {
|
|
315
|
+
if (!/APIRouter\s*\(/.test(readFile(file))) continue;
|
|
316
|
+
const stem = path.basename(file, '.py');
|
|
317
|
+
routerFiles.push(file);
|
|
318
|
+
if (!GENERIC_ROUTER_STEMS.has(stem)) reserved.add(stem);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const moduleNameByFile = new Map();
|
|
322
|
+
const claimed = new Set();
|
|
323
|
+
for (const file of routerFiles) {
|
|
324
|
+
const stem = path.basename(file, '.py');
|
|
325
|
+
if (!GENERIC_ROUTER_STEMS.has(stem)) {
|
|
326
|
+
moduleNameByFile.set(file, stem);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const dir = path.dirname(file);
|
|
330
|
+
const candidate = dir !== projectRoot ? path.basename(dir) : null;
|
|
331
|
+
if (candidate && !reserved.has(candidate) && !claimed.has(candidate)) {
|
|
332
|
+
claimed.add(candidate);
|
|
333
|
+
moduleNameByFile.set(file, candidate);
|
|
334
|
+
} else {
|
|
335
|
+
moduleNameByFile.set(file, stem); // collision or no meaningful parent -- keep the old literal name
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return moduleNameByFile;
|
|
339
|
+
}
|
|
340
|
+
|
|
290
341
|
export function scanPythonFastApi(repoRoot, projectRoot) {
|
|
291
342
|
const files = listPythonFiles(projectRoot);
|
|
343
|
+
const fileTextCache = new Map();
|
|
344
|
+
const readFile = (f) => {
|
|
345
|
+
if (!fileTextCache.has(f)) fileTextCache.set(f, fs.readFileSync(f, 'utf8'));
|
|
346
|
+
return fileTextCache.get(f);
|
|
347
|
+
};
|
|
348
|
+
const moduleNameByFile = resolveGenericModuleNames(files, readFile, projectRoot);
|
|
292
349
|
const modules = new Map();
|
|
293
350
|
const moduleEntry = (name) => {
|
|
294
351
|
if (!modules.has(name)) modules.set(name, { module: name, controllers: [], entities: [], enums: [], dtos: [] });
|
|
@@ -298,13 +355,15 @@ export function scanPythonFastApi(repoRoot, projectRoot) {
|
|
|
298
355
|
const allEntities = [];
|
|
299
356
|
const allDtos = [];
|
|
300
357
|
for (const file of files) {
|
|
301
|
-
const text =
|
|
358
|
+
const text = readFile(file);
|
|
302
359
|
|
|
303
360
|
if (/APIRouter\s*\(/.test(text)) {
|
|
304
361
|
// module = filename stem, NOT the router's own prefix -- verified against the real oracle's
|
|
305
362
|
// login.py, which declares `APIRouter(tags=["login"])` with no prefix at all, so a
|
|
306
363
|
// prefix-derived name fails on a real file while the filename stem works for every one.
|
|
307
|
-
|
|
364
|
+
// D-fastapi-generic-module-name: for a GENERIC stem (`endpoints`/`router`), this is the
|
|
365
|
+
// collision-safe, directory-derived name instead -- see resolveGenericModuleNames() above.
|
|
366
|
+
const moduleName = moduleNameByFile.get(file);
|
|
308
367
|
const routerPrefixes = extractRouterPrefixes(text);
|
|
309
368
|
const rawEndpoints = extractEndpoints(text);
|
|
310
369
|
|
package/scanners/index.mjs
CHANGED
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"matched": { "type": "integer", "minimum": 0, "description": "receipts whose contract_ref equals the CURRENT contract hash -- these count as evidence for the current contract." },
|
|
31
31
|
"stale_contract_ref": { "type": "integer", "minimum": 0, "description": "receipts recorded against a contract_ref that no longer matches -- kept on record for audit/trend purposes, not counted as current evidence." },
|
|
32
32
|
"violations": { "type": "integer", "minimum": 0 },
|
|
33
|
-
"unsupported": { "type": "integer", "minimum": 0 }
|
|
33
|
+
"unsupported": { "type": "integer", "minimum": 0 },
|
|
34
|
+
"unsigned": { "type": "integer", "minimum": 0, "description": "receipts with no signature field -- computed regardless of --pubkey, for forward visibility." },
|
|
35
|
+
"signature_invalid": { "type": "integer", "minimum": 0, "description": "receipts whose signature field failed verification -- only ever non-zero when --pubkey was given; meaningless (always 0) otherwise. Excluded from matched/violations, same as stale_contract_ref." }
|
|
34
36
|
}
|
|
35
37
|
},
|
|
36
38
|
"by_operation": {
|
|
@@ -44,6 +46,15 @@
|
|
|
44
46
|
"violations": { "type": "integer", "minimum": 0 }
|
|
45
47
|
}
|
|
46
48
|
}
|
|
49
|
+
},
|
|
50
|
+
"verification": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"additionalProperties": false,
|
|
53
|
+
"description": "Whether signature verification was even attempted at generation time -- without this, signature_invalid: 0 is ambiguous between 'verified, zero invalid' and 'never checked'. See D-runtime-conformance-receipts.",
|
|
54
|
+
"properties": {
|
|
55
|
+
"pubkey_given": { "type": "boolean" },
|
|
56
|
+
"require_signature": { "type": "boolean" }
|
|
57
|
+
}
|
|
47
58
|
}
|
|
48
59
|
}
|
|
49
60
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "urn:sbf:observe-receipt:1",
|
|
4
4
|
"title": "backend-skeleton runtime contract-conformance receipt",
|
|
5
|
-
"description": "One JSONL line ContractObservationAspect logs per @ObserveContract-annotated call. Verdict-only, by design -- never carries an observed request/response VALUE, only pointers + constraint kinds. See DECISIONS.md D-runtime-conformance-receipts. `bskel observe import --receipts <path>` reads a stream of these.",
|
|
5
|
+
"description": "One JSONL line ContractObservationAspect logs per @ObserveContract-annotated call. Verdict-only, by design -- never carries an observed request/response VALUE, only pointers + constraint kinds. See DECISIONS.md D-runtime-conformance-receipts. `bskel observe import --receipts <path>` reads a stream of these. `signature` is optional -- unsigned receipts stay valid; `bskel observe import --pubkey <path>` opts into verifying it where present. See the D-runtime-conformance-receipts 'cryptographic receipt attestation' Update note.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"required": ["feature_id", "feature_uid", "operation_id", "contract_ref", "verb", "recorded_at", "violations"],
|
|
@@ -27,6 +27,15 @@
|
|
|
27
27
|
"message": { "type": "string", "maxLength": 300 }
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
},
|
|
31
|
+
"signature": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"additionalProperties": false,
|
|
34
|
+
"required": ["algorithm", "value"],
|
|
35
|
+
"properties": {
|
|
36
|
+
"algorithm": { "const": "ed25519" },
|
|
37
|
+
"value": { "type": "string", "description": "base64-encoded raw Ed25519 signature bytes over the canonicalized receipt (this field excluded)." }
|
|
38
|
+
}
|
|
30
39
|
}
|
|
31
40
|
}
|
|
32
41
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"additionalProperties": false,
|
|
7
7
|
"required": ["schema", "terms", "adapter", "verdict", "related_modules", "collisions", "unknowns", "files_read"],
|
|
8
8
|
"properties": {
|
|
9
|
-
"schema": { "const": "sbf.scan-report/
|
|
9
|
+
"schema": { "const": "sbf.scan-report/2" },
|
|
10
10
|
"feature_id": { "type": "string" },
|
|
11
11
|
"terms": { "type": "array", "items": { "type": "string" } },
|
|
12
12
|
"adapter": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
|