@occasiolabs/occasio 0.8.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/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +216 -0
- package/bin/occasio-mcp.js +5 -0
- package/bin/occasio.js +2 -0
- package/bin/supervisor/README.md +90 -0
- package/bin/supervisor/com.occasio.proxy.plist.template +36 -0
- package/bin/supervisor/install-windows-task.ps1 +48 -0
- package/bin/supervisor/occasio.service +18 -0
- package/docs/AUDIT.md +120 -0
- package/docs/attest_verify.py +283 -0
- package/docs/audit_walker.py +65 -0
- package/docs/canonicalize.py +99 -0
- package/docs/compliance-mapping.md +93 -0
- package/docs/demos/mcp-block.md +148 -0
- package/docs/edr-calibration.md +73 -0
- package/docs/edr-demo.md +83 -0
- package/docs/python-verifier.md +74 -0
- package/docs/reference-pipeline.md +140 -0
- package/package.json +69 -0
- package/policy-templates/dev-default.yml +84 -0
- package/policy-templates/finance.yml +61 -0
- package/policy-templates/strict.yml +49 -0
- package/schemas/agent-attestation-v1.json +190 -0
- package/schemas/occasio-policy.schema.json +99 -0
- package/spec/agent-attestation/v1/README.md +137 -0
- package/src/adapters/claude-code.js +518 -0
- package/src/adapters/cline.js +161 -0
- package/src/adapters/computer-use-cli.js +198 -0
- package/src/adapters/computer-use.js +227 -0
- package/src/analyzer.js +170 -0
- package/src/anomaly/cli.js +143 -0
- package/src/anomaly/detectors/deny-rate.js +84 -0
- package/src/anomaly/detectors/file-read-volume.js +109 -0
- package/src/anomaly/detectors/secret-redact-rate.js +107 -0
- package/src/anomaly/detectors/unknown-tool-input.js +83 -0
- package/src/anomaly/index.js +169 -0
- package/src/attest/canonicalize.js +97 -0
- package/src/attest/index.js +355 -0
- package/src/attest/run-slice.js +57 -0
- package/src/attest/sign.js +186 -0
- package/src/attest/verify.js +192 -0
- package/src/audit/errors.js +21 -0
- package/src/audit/input-normalizer.js +121 -0
- package/src/audit/jsonl-auditor.js +178 -0
- package/src/audit/verifier.js +152 -0
- package/src/baseline.js +507 -0
- package/src/boundary.js +238 -0
- package/src/budget.js +42 -0
- package/src/classifier.js +115 -0
- package/src/context-budget.js +77 -0
- package/src/core/boundary-event.js +75 -0
- package/src/core/decision.js +61 -0
- package/src/core/pipeline.js +66 -0
- package/src/core/tool-names.js +105 -0
- package/src/dashboard.js +892 -0
- package/src/demo/README.md +31 -0
- package/src/demo/anomalies-demo.js +211 -0
- package/src/demo/attest-demo.js +198 -0
- package/src/distiller.js +155 -0
- package/src/embeddings.json +72 -0
- package/src/executor/dispatcher.js +230 -0
- package/src/harness.js +817 -0
- package/src/index.js +1711 -0
- package/src/inspect.js +329 -0
- package/src/interceptor.js +1198 -0
- package/src/lao.js +185 -0
- package/src/lao_prep.py +119 -0
- package/src/ledger.js +209 -0
- package/src/mcp-experiment.js +140 -0
- package/src/mcp-normalize.js +139 -0
- package/src/mcp-server.js +320 -0
- package/src/outbound-policy.js +433 -0
- package/src/policy/built-in-classifiers.js +78 -0
- package/src/policy/doctor.js +226 -0
- package/src/policy/engine.js +339 -0
- package/src/policy/init.js +153 -0
- package/src/policy/loader.js +448 -0
- package/src/policy/rules-default.js +36 -0
- package/src/policy/shell-path.js +135 -0
- package/src/policy/show.js +196 -0
- package/src/policy/validate.js +310 -0
- package/src/preflight/cli.js +164 -0
- package/src/preflight/miner.js +329 -0
- package/src/proxy/agent-router.js +93 -0
- package/src/redteam.js +428 -0
- package/src/replay.js +446 -0
- package/src/report/index.js +224 -0
- package/src/runtime.js +595 -0
- package/src/scanner/index.js +49 -0
- package/src/selftest.js +192 -0
- package/src/session.js +36 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sign.js — wrap a Occasio Phase-0 attestation in an in-toto Statement v1
|
|
5
|
+
* envelope and Sigstore-sign it keyless. Outputs (a) a populated
|
|
6
|
+
* `signature` object to inline in the predicate JSON, and (b) the Sigstore
|
|
7
|
+
* Bundle as a sidecar file for cryptographic verification.
|
|
8
|
+
*
|
|
9
|
+
* Two signing modes:
|
|
10
|
+
* - 'ci' — GitHub Actions OIDC. Reads ACTIONS_ID_TOKEN_REQUEST_URL /
|
|
11
|
+
* ACTIONS_ID_TOKEN_REQUEST_TOKEN and exchanges for a Sigstore
|
|
12
|
+
* identity token. The standard CI flow.
|
|
13
|
+
* - 'token'— `--oidc-token <jwt>` was provided explicitly (CI-emulate
|
|
14
|
+
* for tests + manual runs).
|
|
15
|
+
*
|
|
16
|
+
* No local browser flow in Phase 1 by design (see plan: lokaler Browser-Flow
|
|
17
|
+
* wird später nachgerüstet falls Bedarf da ist).
|
|
18
|
+
*
|
|
19
|
+
* The function is decoupled from sigstore-js via lazy require so tests can
|
|
20
|
+
* stub the module without paying its import cost on every CLI invocation.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
const { canonicalize } = require('./canonicalize');
|
|
25
|
+
|
|
26
|
+
const PREDICATE_TYPE =
|
|
27
|
+
'https://github.com/occasiolabs/occasio/spec/agent-attestation/v1';
|
|
28
|
+
const STATEMENT_TYPE = 'https://in-toto.io/Statement/v1';
|
|
29
|
+
const DSSE_PAYLOAD_TYPE = 'application/vnd.in-toto+json';
|
|
30
|
+
const SIGSTORE_AUDIENCE = 'sigstore';
|
|
31
|
+
|
|
32
|
+
// ── in-toto Statement envelope ───────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the in-toto Statement v1 that will be signed. The `subject` carries
|
|
36
|
+
* the audit-chain commitment: `last_hash` is itself a SHA-256 over the entire
|
|
37
|
+
* run-id slice (each prev_hash chains back to GENESIS), so signing it commits
|
|
38
|
+
* cryptographically to every event in the slice.
|
|
39
|
+
*/
|
|
40
|
+
function buildInTotoStatement(attestation) {
|
|
41
|
+
if (!attestation || !attestation.audit_chain || !attestation.audit_chain.last_hash) {
|
|
42
|
+
throw new Error('sign: attestation has no audit_chain.last_hash — refusing to sign an empty slice');
|
|
43
|
+
}
|
|
44
|
+
// The predicate JSON we sign should NOT carry a placeholder `signature` field;
|
|
45
|
+
// signature metadata is filled into the predicate AFTER signing succeeds.
|
|
46
|
+
const { signature: _omit, ...predicate } = attestation;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
_type: STATEMENT_TYPE,
|
|
50
|
+
predicateType: PREDICATE_TYPE,
|
|
51
|
+
subject: [
|
|
52
|
+
{
|
|
53
|
+
name: `occasio:run:${attestation.subject.run_id}`,
|
|
54
|
+
digest: { sha256: attestation.audit_chain.last_hash },
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
predicate,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── OIDC token acquisition ───────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fetch a Sigstore-audience identity token from the GitHub Actions OIDC
|
|
65
|
+
* provider. Requires `permissions: id-token: write` in the workflow.
|
|
66
|
+
* Returns the JWT string.
|
|
67
|
+
*/
|
|
68
|
+
async function fetchGithubActionsToken() {
|
|
69
|
+
const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
|
70
|
+
const reqToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
71
|
+
if (!url || !reqToken) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"sign: GitHub Actions OIDC env vars missing. " +
|
|
74
|
+
"Workflow needs `permissions: id-token: write`. " +
|
|
75
|
+
"Vars: ACTIONS_ID_TOKEN_REQUEST_URL, ACTIONS_ID_TOKEN_REQUEST_TOKEN."
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const fetchUrl = `${url}&audience=${encodeURIComponent(SIGSTORE_AUDIENCE)}`;
|
|
79
|
+
const res = await fetch(fetchUrl, {
|
|
80
|
+
headers: { Authorization: `Bearer ${reqToken}` },
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new Error(`sign: OIDC token request failed: ${res.status} ${res.statusText}`);
|
|
84
|
+
}
|
|
85
|
+
const body = await res.json();
|
|
86
|
+
if (!body || typeof body.value !== 'string') {
|
|
87
|
+
throw new Error('sign: OIDC token response missing `value`');
|
|
88
|
+
}
|
|
89
|
+
return body.value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── identity decoding (best-effort, no signature check) ──────────────────────
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decode the OIDC JWT payload for the `iss` + `sub` claims. We only use these
|
|
96
|
+
* for the human-readable `signature.identity` field — the cryptographic
|
|
97
|
+
* binding is in the Sigstore certificate, not here. Best-effort: if decoding
|
|
98
|
+
* fails we fall back to `'unknown'`.
|
|
99
|
+
*/
|
|
100
|
+
function decodeIdentity(jwt) {
|
|
101
|
+
try {
|
|
102
|
+
const parts = jwt.split('.');
|
|
103
|
+
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
104
|
+
if (claims.sub) return claims.sub; // GitHub: workflow ref
|
|
105
|
+
if (claims.iss && claims.email) return `${claims.iss}#${claims.email}`;
|
|
106
|
+
return claims.iss || 'unknown';
|
|
107
|
+
} catch { return 'unknown'; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Bundle parsing (extract Rekor entry URL for human display) ───────────────
|
|
111
|
+
|
|
112
|
+
function extractRekorEntry(bundle) {
|
|
113
|
+
try {
|
|
114
|
+
const entry = bundle?.verificationMaterial?.tlogEntries?.[0];
|
|
115
|
+
if (!entry || !entry.logIndex) return null;
|
|
116
|
+
// Public Rekor instance URL format. Private Sigstore deployments would
|
|
117
|
+
// need a different base URL — Phase 1 supports public-good only.
|
|
118
|
+
return `https://search.sigstore.dev/?logIndex=${entry.logIndex}`;
|
|
119
|
+
} catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── public API ───────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Sign an attestation. Returns { bundle, signatureBlock, statementJSON }.
|
|
126
|
+
*
|
|
127
|
+
* ctx.oidcToken — explicit JWT (token-mode); takes precedence
|
|
128
|
+
* ctx.signMode — 'ci' | 'token' (auto-detected if absent)
|
|
129
|
+
* ctx.sigstoreModule — DI for tests; defaults to require('sigstore')
|
|
130
|
+
* ctx.now — DI for tests; defaults to () => new Date()
|
|
131
|
+
* ctx.tlogUpload — default true (Rekor log entry). Set false in tests.
|
|
132
|
+
*/
|
|
133
|
+
async function signAttestation(attestation, ctx = {}) {
|
|
134
|
+
const statement = buildInTotoStatement(attestation);
|
|
135
|
+
// Canonical JSON: makes byte-equivalence in verify.js deterministic across
|
|
136
|
+
// runtime versions and re-serialisation steps. The verifier re-canonicalises
|
|
137
|
+
// the parsed predicate from the bundle before comparing — both sides agree.
|
|
138
|
+
const statementJSON = canonicalize(statement);
|
|
139
|
+
const payload = Buffer.from(statementJSON, 'utf8');
|
|
140
|
+
|
|
141
|
+
// Token acquisition
|
|
142
|
+
let identityToken;
|
|
143
|
+
let mode = ctx.signMode;
|
|
144
|
+
if (ctx.oidcToken) {
|
|
145
|
+
identityToken = ctx.oidcToken;
|
|
146
|
+
mode = mode || 'token';
|
|
147
|
+
} else {
|
|
148
|
+
mode = mode || (process.env.GITHUB_ACTIONS === 'true' ? 'ci' : null);
|
|
149
|
+
if (mode !== 'ci') {
|
|
150
|
+
throw new Error(
|
|
151
|
+
"sign: no OIDC token. Pass --oidc-token <jwt> or run inside " +
|
|
152
|
+
"GitHub Actions with `permissions: id-token: write`."
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
identityToken = await fetchGithubActionsToken();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Sigstore sign (keyless)
|
|
159
|
+
const sigstore = ctx.sigstoreModule || require('sigstore');
|
|
160
|
+
const bundle = await sigstore.attest(payload, DSSE_PAYLOAD_TYPE, {
|
|
161
|
+
identityToken,
|
|
162
|
+
tlogUpload: ctx.tlogUpload !== false,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const now = (ctx.now ? ctx.now() : new Date()).toISOString();
|
|
166
|
+
const signatureBlock = {
|
|
167
|
+
type: 'sigstore-cosign-keyless',
|
|
168
|
+
identity: decodeIdentity(identityToken),
|
|
169
|
+
rekor_entry: extractRekorEntry(bundle),
|
|
170
|
+
signed_at: now,
|
|
171
|
+
envelope_sha256: crypto.createHash('sha256').update(payload).digest('hex'),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
return { bundle, signatureBlock, statementJSON };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
signAttestation,
|
|
179
|
+
buildInTotoStatement,
|
|
180
|
+
PREDICATE_TYPE,
|
|
181
|
+
STATEMENT_TYPE,
|
|
182
|
+
DSSE_PAYLOAD_TYPE,
|
|
183
|
+
// for tests
|
|
184
|
+
_decodeIdentity: decodeIdentity,
|
|
185
|
+
_extractRekorEntry: extractRekorEntry,
|
|
186
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* verify.js — re-verify a signed Occasio Agent Attestation end-to-end.
|
|
5
|
+
*
|
|
6
|
+
* Three independent checks, in order — each must pass:
|
|
7
|
+
* 1. Sigstore bundle is cryptographically valid against Fulcio/Rekor.
|
|
8
|
+
* 2. The DSSE payload inside the bundle parses as an in-toto Statement v1
|
|
9
|
+
* whose `predicate` field, when re-stringified, matches the attestation
|
|
10
|
+
* JSON we hold (modulo the `signature` field, which is metadata-only and
|
|
11
|
+
* excluded before comparison).
|
|
12
|
+
* 3. The audit chain (`chain_file` in the attestation) verifies via the
|
|
13
|
+
* same walker that `occasio audit verify` uses, AND the
|
|
14
|
+
* `first_hash`/`last_hash` claimed in the attestation exist in that
|
|
15
|
+
* chain in the correct order.
|
|
16
|
+
*
|
|
17
|
+
* Returns:
|
|
18
|
+
* { ok, checks: [{ name, ok, detail }], identity, rekor_entry }
|
|
19
|
+
*
|
|
20
|
+
* Phase 1 keeps the verifier strict: any failure → ok:false. Callers (CLI,
|
|
21
|
+
* View-Evidence page) render the `checks` list verbatim — no aggregation.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const { verifyFile } = require('../audit/verifier');
|
|
28
|
+
const { DSSE_PAYLOAD_TYPE, PREDICATE_TYPE } = require('./sign');
|
|
29
|
+
const { canonicalize } = require('./canonicalize');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Verify a saved attestation pair.
|
|
33
|
+
*
|
|
34
|
+
* attestationPath — path to the predicate JSON (Phase-0 / Phase-1 shape)
|
|
35
|
+
* bundlePath — path to the Sigstore Bundle sidecar
|
|
36
|
+
* ctx.sigstoreModule — DI for tests
|
|
37
|
+
*/
|
|
38
|
+
async function verifyAttestation(attestationPath, bundlePath, ctx = {}) {
|
|
39
|
+
const checks = [];
|
|
40
|
+
let identity = null, rekor_entry = null;
|
|
41
|
+
|
|
42
|
+
// Load both files up front; surface any I/O failure as the first check.
|
|
43
|
+
let attestation, bundle;
|
|
44
|
+
try {
|
|
45
|
+
attestation = JSON.parse(fs.readFileSync(attestationPath, 'utf8'));
|
|
46
|
+
} catch (e) {
|
|
47
|
+
checks.push({ name: 'read attestation', ok: false, detail: e.message });
|
|
48
|
+
return { ok: false, checks, identity, rekor_entry };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
bundle = JSON.parse(fs.readFileSync(bundlePath, 'utf8'));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
checks.push({ name: 'read bundle', ok: false, detail: e.message });
|
|
54
|
+
return { ok: false, checks, identity, rekor_entry };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── 1. Sigstore signature ───────────────────────────────────────────────
|
|
58
|
+
try {
|
|
59
|
+
const sigstore = ctx.sigstoreModule || require('sigstore');
|
|
60
|
+
// sigstore.verify with no data arg verifies the bundle's embedded payload.
|
|
61
|
+
await sigstore.verify(bundle);
|
|
62
|
+
checks.push({ name: 'sigstore signature', ok: true });
|
|
63
|
+
// Surface the Fulcio identity for human display (not for trust decisions —
|
|
64
|
+
// sigstore.verify already enforced the cert chain).
|
|
65
|
+
rekor_entry = (() => {
|
|
66
|
+
try { return bundle.verificationMaterial?.tlogEntries?.[0]?.logIndex || null; }
|
|
67
|
+
catch { return null; }
|
|
68
|
+
})();
|
|
69
|
+
identity = attestation?.signature?.identity || null;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
checks.push({ name: 'sigstore signature', ok: false, detail: e.message });
|
|
72
|
+
return { ok: false, checks, identity, rekor_entry };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── 2. DSSE payload ↔ attestation predicate equivalence ────────────────
|
|
76
|
+
try {
|
|
77
|
+
const env = bundle.dsseEnvelope;
|
|
78
|
+
if (!env || !env.payload) throw new Error('bundle missing dsseEnvelope.payload');
|
|
79
|
+
if (env.payloadType !== DSSE_PAYLOAD_TYPE) {
|
|
80
|
+
throw new Error(`unexpected payloadType: ${env.payloadType}`);
|
|
81
|
+
}
|
|
82
|
+
const stmtJson = Buffer.from(env.payload, 'base64').toString('utf8');
|
|
83
|
+
const stmt = JSON.parse(stmtJson);
|
|
84
|
+
|
|
85
|
+
if (stmt.predicateType !== PREDICATE_TYPE) {
|
|
86
|
+
throw new Error(`unexpected predicateType: ${stmt.predicateType}`);
|
|
87
|
+
}
|
|
88
|
+
// Recompute what we WOULD have signed for this attestation, ignoring the
|
|
89
|
+
// signature metadata field. Canonical JSON on both sides — any key-order
|
|
90
|
+
// or whitespace divergence between runtimes/versions becomes invisible,
|
|
91
|
+
// and only real semantic mismatches surface.
|
|
92
|
+
const { signature: _omit, ...predicateExpected } = attestation;
|
|
93
|
+
if (canonicalize(stmt.predicate) !== canonicalize(predicateExpected)) {
|
|
94
|
+
throw new Error('attestation predicate differs from DSSE payload predicate');
|
|
95
|
+
}
|
|
96
|
+
checks.push({ name: 'bundle payload matches attestation', ok: true });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
checks.push({ name: 'bundle payload matches attestation', ok: false, detail: e.message });
|
|
99
|
+
return { ok: false, checks, identity, rekor_entry };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── 3. Audit chain integrity + hash commitments ─────────────────────────
|
|
103
|
+
try {
|
|
104
|
+
const chainFile = attestation?.audit_chain?.chain_file;
|
|
105
|
+
if (!chainFile) throw new Error('attestation missing audit_chain.chain_file');
|
|
106
|
+
if (!fs.existsSync(chainFile)) throw new Error(`chain file not found: ${chainFile}`);
|
|
107
|
+
|
|
108
|
+
const ver = verifyFile(chainFile);
|
|
109
|
+
if (!ver.ok) {
|
|
110
|
+
throw new Error(`chain broken at line(s): ${(ver.errors || []).map(e => e.line).join(', ')}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Confirm the claimed first/last hashes actually appear in the chain in
|
|
114
|
+
// the right relative order. Walk lines once; reuse existing readJsonl path.
|
|
115
|
+
const lines = fs.readFileSync(chainFile, 'utf8').split('\n').filter(Boolean);
|
|
116
|
+
let firstIdx = -1, lastIdx = -1;
|
|
117
|
+
for (let i = 0; i < lines.length; i++) {
|
|
118
|
+
let row;
|
|
119
|
+
try { row = JSON.parse(lines[i]); } catch { continue; }
|
|
120
|
+
if (row.hash === attestation.audit_chain.first_hash && firstIdx === -1) firstIdx = i;
|
|
121
|
+
if (row.hash === attestation.audit_chain.last_hash) lastIdx = i;
|
|
122
|
+
}
|
|
123
|
+
if (firstIdx === -1) throw new Error(`first_hash not found in chain`);
|
|
124
|
+
if (lastIdx === -1) throw new Error(`last_hash not found in chain`);
|
|
125
|
+
if (lastIdx < firstIdx) throw new Error(`last_hash precedes first_hash in chain`);
|
|
126
|
+
|
|
127
|
+
checks.push({ name: 'audit chain integrity', ok: true,
|
|
128
|
+
detail: `chain_length=${ver.chained}, slice rows ${firstIdx + 1}..${lastIdx + 1}` });
|
|
129
|
+
} catch (e) {
|
|
130
|
+
checks.push({ name: 'audit chain integrity', ok: false, detail: e.message });
|
|
131
|
+
return { ok: false, checks, identity, rekor_entry };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
ok: checks.every(c => c.ok),
|
|
136
|
+
checks, identity, rekor_entry,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async function runAttestVerifyCli(args) {
|
|
143
|
+
args = args || [];
|
|
144
|
+
if (args.length === 0 || args[0].startsWith('-') && args[0] !== '--help') {
|
|
145
|
+
process.stderr.write('Usage: occasio attest verify <attestation.json> [--bundle <path>]\n');
|
|
146
|
+
process.exit(2);
|
|
147
|
+
}
|
|
148
|
+
if (args[0] === '--help' || args[0] === '-h') {
|
|
149
|
+
process.stdout.write(
|
|
150
|
+
'Usage: occasio attest verify <attestation.json> [--bundle <path>]\n' +
|
|
151
|
+
'\n' +
|
|
152
|
+
'Default bundle path: <attestation>.sigstore.json (sidecar convention).\n'
|
|
153
|
+
);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const attestationPath = path.resolve(args[0]);
|
|
157
|
+
const bundleIdx = args.indexOf('--bundle');
|
|
158
|
+
const bundlePath = bundleIdx >= 0
|
|
159
|
+
? path.resolve(args[bundleIdx + 1])
|
|
160
|
+
: attestationPath.replace(/\.json$/i, '') + '.sigstore.json';
|
|
161
|
+
|
|
162
|
+
const col = {
|
|
163
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
164
|
+
r: s => `\x1b[31m${s}\x1b[0m`,
|
|
165
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
166
|
+
b: s => `\x1b[1m${s}\x1b[0m`,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
process.stdout.write(`${col.b('Verifying')} ${attestationPath}\n`);
|
|
170
|
+
process.stdout.write(` bundle: ${bundlePath}\n\n`);
|
|
171
|
+
|
|
172
|
+
let result;
|
|
173
|
+
try {
|
|
174
|
+
result = await verifyAttestation(attestationPath, bundlePath);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
process.stderr.write(`${col.r('✗')} verify crashed: ${e.message}\n`);
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const c of result.checks) {
|
|
181
|
+
const mark = c.ok ? col.g('✓') : col.r('✗');
|
|
182
|
+
process.stdout.write(` ${mark} ${c.name}`);
|
|
183
|
+
if (c.detail) process.stdout.write(` ${col.d(c.detail)}`);
|
|
184
|
+
process.stdout.write('\n');
|
|
185
|
+
}
|
|
186
|
+
if (result.identity) process.stdout.write(`\n ${col.d('identity:')} ${result.identity}\n`);
|
|
187
|
+
if (result.rekor_entry) process.stdout.write(` ${col.d('rekor index:')} ${result.rekor_entry}\n`);
|
|
188
|
+
|
|
189
|
+
process.exit(result.ok ? 0 : 1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { verifyAttestation, runAttestVerifyCli };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Audit-layer error classes.
|
|
5
|
+
*
|
|
6
|
+
* AuditWriteError signals that the auditor could not append a row to its
|
|
7
|
+
* sink. v0.6.4 treats this as session-fatal — see src/index.js's request
|
|
8
|
+
* handler for the abort behaviour. The dropped row is attached so a
|
|
9
|
+
* supervisor / log scraper can recover it from stderr if needed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
class AuditWriteError extends Error {
|
|
13
|
+
constructor(cause, droppedRow) {
|
|
14
|
+
super(`audit write failed: ${cause && cause.message ? cause.message : cause}`);
|
|
15
|
+
this.name = 'AuditWriteError';
|
|
16
|
+
this.cause = cause;
|
|
17
|
+
this.droppedRow = droppedRow;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { AuditWriteError };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Audit input normalizer (ARCH-27).
|
|
5
|
+
*
|
|
6
|
+
* Produces a safe, governance-useful `tool_inputs` object for each tool call.
|
|
7
|
+
* The guiding principle: log file-system metadata (what was accessed, where),
|
|
8
|
+
* never log content-search strings or anything that could be a credential value.
|
|
9
|
+
*
|
|
10
|
+
* Per-tool scope:
|
|
11
|
+
* read_file → { path } resolved absolute path
|
|
12
|
+
* find_files → { pattern?, path? } glob pattern only if structurally a glob
|
|
13
|
+
* grep → { path?, glob? } pattern OMITTED intentionally
|
|
14
|
+
* todo_write → { count } item count only, no content
|
|
15
|
+
* todo_read → (absent)
|
|
16
|
+
* shell tools → (absent)
|
|
17
|
+
* all others → (absent)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { scanSecrets, redactSecrets } = require('../analyzer');
|
|
23
|
+
|
|
24
|
+
// Glob metacharacters that distinguish structural patterns from content strings.
|
|
25
|
+
const GLOB_META = /[*?[{!]/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Normalize tool inputs to a safe, loggable shape.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} toolName Canonical tool name (e.g. 'read_file')
|
|
31
|
+
* @param {object} toolInput Raw tool input from the BoundaryEvent
|
|
32
|
+
* @returns {object|undefined} Safe input object, or undefined if nothing to log
|
|
33
|
+
*/
|
|
34
|
+
function normalizeToolInputsForAudit(toolName, toolInput) {
|
|
35
|
+
if (!toolInput) return undefined;
|
|
36
|
+
|
|
37
|
+
switch (toolName) {
|
|
38
|
+
case 'read_file': {
|
|
39
|
+
const raw = toolInput.file_path;
|
|
40
|
+
if (typeof raw !== 'string' || !raw) return undefined;
|
|
41
|
+
const resolved = expandAndResolve(raw);
|
|
42
|
+
const { value: masked, wasMasked } = scanAndMask(resolved);
|
|
43
|
+
const out = { path: masked };
|
|
44
|
+
if (wasMasked) out.path_masked = true;
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'find_files': {
|
|
49
|
+
const rawPattern = toolInput.pattern;
|
|
50
|
+
const rawPath = toolInput.path;
|
|
51
|
+
const out = {};
|
|
52
|
+
|
|
53
|
+
if (typeof rawPattern === 'string' && rawPattern) {
|
|
54
|
+
// Only log if it looks like a real glob (has metacharacters) or is short enough
|
|
55
|
+
// to be safe. A long string with no glob syntax is likely a content string.
|
|
56
|
+
if (GLOB_META.test(rawPattern) || rawPattern.length <= 80) {
|
|
57
|
+
const { value: masked, wasMasked } = scanAndMask(rawPattern);
|
|
58
|
+
out.pattern = masked;
|
|
59
|
+
if (wasMasked) out.pattern_masked = true;
|
|
60
|
+
}
|
|
61
|
+
// else: no metacharacters AND > 80 chars → omit (outside safe envelope)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof rawPath === 'string' && rawPath) {
|
|
65
|
+
out.path = expandAndResolve(rawPath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Object.keys(out).length ? out : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case 'grep': {
|
|
72
|
+
// Pattern intentionally omitted — free-form search strings can contain
|
|
73
|
+
// arbitrary sensitive content the built-in scanner cannot bound.
|
|
74
|
+
// Log only WHERE the agent searched (path + file-filter).
|
|
75
|
+
const out = {};
|
|
76
|
+
const rawPath = toolInput.path;
|
|
77
|
+
const rawGlob = toolInput.glob;
|
|
78
|
+
|
|
79
|
+
if (typeof rawPath === 'string' && rawPath) {
|
|
80
|
+
out.path = expandAndResolve(rawPath);
|
|
81
|
+
}
|
|
82
|
+
if (typeof rawGlob === 'string' && rawGlob) {
|
|
83
|
+
out.glob = rawGlob;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return Object.keys(out).length ? out : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case 'todo_write': {
|
|
90
|
+
const count = Array.isArray(toolInput.todos) ? toolInput.todos.length : 0;
|
|
91
|
+
return { count };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// todo_read, shell_bash, shell_powershell, unknown tools → nothing to log
|
|
95
|
+
default:
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
/** Expand leading ~ and path.resolve to absolute. */
|
|
103
|
+
function expandAndResolve(p) {
|
|
104
|
+
if (typeof p !== 'string') return p;
|
|
105
|
+
const expanded = p.startsWith('~') ? os.homedir() + p.slice(1) : p;
|
|
106
|
+
return path.resolve(expanded);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Scan a short string (path, pattern) for embedded secrets.
|
|
111
|
+
* Returns the original value if clean, or a redacted version if a hit was found.
|
|
112
|
+
* This is a safeguard for edge cases (e.g., a filename that IS a token); it is
|
|
113
|
+
* not expected to fire under normal use.
|
|
114
|
+
*/
|
|
115
|
+
function scanAndMask(value) {
|
|
116
|
+
const hits = scanSecrets(value);
|
|
117
|
+
if (!hits || hits.length === 0) return { value, wasMasked: false };
|
|
118
|
+
return { value: redactSecrets(value), wasMasked: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { normalizeToolInputsForAudit };
|