@clear-capabilities/agentic-security-scanner 0.169.0 → 0.170.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/CHANGELOG.md CHANGED
@@ -9,6 +9,43 @@
9
9
  > make the history less accurate, not more.
10
10
 
11
11
 
12
+ ## 0.170.0 - source-to-artifact security & provenance: a new `src/artifact/` package, `image`/`provenance` CLI, VEX
13
+
14
+ Binds a finding to the actual **built artifact** (a container image, today) that ships it — closing the gap
15
+ between "this dependency has a CVE" and "this specific image digest actually ships it." A new, isolated
16
+ `scanner/src/artifact/` package reuses the existing SCA pipeline (manifest parsers, OS-package-DB parser,
17
+ OSV/KEV/EPSS correlation) rather than building a parallel one.
18
+
19
+ **OCI image scanning + inventory.** A hand-rolled, hardened tar/gzip reader (`safe-tar.js`) — hard caps on entry
20
+ count/size/archive size, a native decompression-bomb guard, and a path-safety check that flags (never silently
21
+ normalizes) a `..`/absolute tar entry path. Parses a `docker save`-shaped archive or an OCI-layout archive into
22
+ digest/OS/architecture/base-image/layers/entrypoint/build-labels, with OS and application packages extracted
23
+ through the exact same parsers every other SCA finding in this codebase uses. Never executes the image — no
24
+ `docker run`, no daemon required for the archive path.
25
+
26
+ **Layer secret analysis.** A secret added in one layer and deleted by a later overlay whiteout is still sitting
27
+ in that earlier layer's raw tar bytes — detected and reported (value always masked/redacted, the detector is
28
+ injected, same shape `posture/secret-history.js` already uses for the git-history analog).
29
+
30
+ **Signature + attestation verification**, states exactly `verified`/`invalid`/`missing`/`unavailable` — a
31
+ detached-signature check over the image digest, plus REAL DSSE (Pre-Authentication Encoding) verification for
32
+ in-toto/SLSA provenance attestations, so a real cosign-keyed attestation bundle verifies here given the matching
33
+ public key. Never treats missing material as verified.
34
+
35
+ **The provenance graph** (Git Commit → Build → OCI Digest → SBOM Component → Finding → Deployment) and
36
+ **source-vs-artifact mismatch detection** — the PRD's own "Killer Demonstration": a developer fixes a
37
+ vulnerability in source, but production is still running the old image. `SOURCE: FIXED` / `DEPLOYED ARTIFACT:
38
+ STILL VULNERABLE` / `PRODUCTION EXPOSURE: PRESENT`, proven end-to-end against the real built CLI. **VEX**
39
+ statements (affected/not affected/fixed/under investigation) — `not affected` only ever from positive evidence
40
+ (component absent, or confirmed unreachable), never from absence of information.
41
+
42
+ **CLI:** `agentic-security image scan/verify`, `agentic-security provenance trace`, `agentic-security supply vex`
43
+ — the PRD's own literal CLI shape. New slash commands `/image`, `/provenance`; `/supply` gained a `--vex` mode.
44
+
45
+ **Deliberately NOT done, disclosed:** a local Docker daemon image reference and a remote OCI registry reference
46
+ as `image scan` inputs (only local docker-archive/oci-archive tar files); keyless Rekor-transparency-log
47
+ signature verification. See `docs/implementation-status/source-to-artifact-provenance.md`.
48
+
12
49
  ## 0.169.0 - runtime-corroborated security twin: 4 new telemetry adapters, honest runtime assurance, a `runtime` CLI
13
50
 
14
51
  Extends the existing Milestone 5 "Runtime-Corroborated Digital Twin" machinery (closed-world `RuntimeObservation`
@@ -5778,6 +5778,295 @@ async function cmdRuntimeQuery(args) {
5778
5778
  return 0;
5779
5779
  }
5780
5780
 
5781
+ // ─── PRD 5: Source-to-Artifact Security & Provenance ───────────────────────
5782
+ // `image scan/verify`, `provenance trace`, `supply vex`. See
5783
+ // docs/implementation-status/source-to-artifact-provenance.md for the full
5784
+ // scope, naming-collision discipline (why the graph field below is
5785
+ // `artifactProvenanceGraph`, never a bare `.provenance`), and what is
5786
+ // deliberately NOT implemented (local-daemon image / remote registry
5787
+ // reference inputs; keyless Rekor signature lookup).
5788
+
5789
+ function _readFileOrError(target) {
5790
+ const abs = path.resolve(target);
5791
+ if (!fs.existsSync(abs)) return { ok: false, message: `no such file: ${target}` };
5792
+ if (!fs.statSync(abs).isFile()) return { ok: false, message: `${target} is not a file` };
5793
+ try {
5794
+ return { ok: true, buf: fs.readFileSync(abs) };
5795
+ } catch (err) {
5796
+ return { ok: false, message: `failed to read ${target}: ${err.message}` };
5797
+ }
5798
+ }
5799
+
5800
+ function _artifactScanDigestHex(digestOrTarget) {
5801
+ const s = String(digestOrTarget || '');
5802
+ const hex = s.startsWith('sha256:') ? s.slice(7) : s;
5803
+ return /^[0-9a-f]{64}$/.test(hex) ? hex : null;
5804
+ }
5805
+
5806
+ // Parses an archive fresh and runs inventory + vuln correlation — the one
5807
+ // real analysis path every `image scan`/`provenance trace`/`supply vex`
5808
+ // invocation that isn't reading a stored result funnels through.
5809
+ async function _scanArchiveFresh(buf) {
5810
+ const { buildImageInventory } = await import('../src/artifact/inventory.js');
5811
+ const { ImageArchiveError } = await import('../src/artifact/image-archive.js');
5812
+ const { TarSafetyError } = await import('../src/artifact/safe-tar.js');
5813
+ const { parseManifests, queryOSV } = await import('../src/sca/index.js');
5814
+ const { correlateImageVulnerabilities } = await import('../src/artifact/vuln-correlate.js');
5815
+ const { analyzeLayerSecrets } = await import('../src/artifact/layer-secrets.js');
5816
+ const { scanCredentials } = await import('../src/secrets/index.js');
5817
+
5818
+ let built;
5819
+ try {
5820
+ built = buildImageInventory(buf, { manifestParser: parseManifests });
5821
+ } catch (err) {
5822
+ if (err instanceof ImageArchiveError || err instanceof TarSafetyError) return { ok: false, message: err.message };
5823
+ throw err;
5824
+ }
5825
+ const { vulnerabilities } = await correlateImageVulnerabilities({
5826
+ inventory: built.inventory, fileContents: built.mergedFilesystem.fileContents, queryOSVFn: queryOSV,
5827
+ });
5828
+ const secrets = analyzeLayerSecrets({
5829
+ readLayersResult: built.readLayersResult, deletedPaths: built.mergedFilesystem.deletedPaths, detectFn: scanCredentials,
5830
+ });
5831
+ return {
5832
+ ok: true,
5833
+ built,
5834
+ record: {
5835
+ inventory: built.inventory,
5836
+ vulnerabilities,
5837
+ secrets: { deletedButRecoverable: secrets.deletedButRecoverable, totalFindings: secrets.findings.length },
5838
+ scannedAt: new Date().toISOString(),
5839
+ },
5840
+ };
5841
+ }
5842
+
5843
+ async function _persistArtifactScan(record) {
5844
+ const { statePath, safeWriteState } = await import('../src/posture/state-dir.js');
5845
+ const root = process.cwd();
5846
+ const hex = _artifactScanDigestHex(record.inventory.digest);
5847
+ if (!hex) return false; // defensive: digest is always well-formed sha256:<hex> from image-archive.js
5848
+ return safeWriteState(statePath(root, 'artifact-scans', `${hex}.json`), JSON.stringify(record, null, 2), { category: 'artifact-scans' });
5849
+ }
5850
+
5851
+ // Resolves `target` to a scan record: a digest/sha256:<hex> that matches a
5852
+ // PREVIOUSLY `image scan`-ned, persisted record is loaded from state;
5853
+ // anything else is treated as an archive file path and parsed fresh. This
5854
+ // is what lets `provenance trace <digest>` and `supply vex <digest>` work
5855
+ // after an earlier `image scan <path>` without re-reading the archive.
5856
+ async function _resolveArtifactScan(target) {
5857
+ const hex = _artifactScanDigestHex(target);
5858
+ if (hex) {
5859
+ const { statePath } = await import('../src/posture/state-dir.js');
5860
+ const storedPath = statePath(process.cwd(), 'artifact-scans', `${hex}.json`);
5861
+ if (fs.existsSync(storedPath)) {
5862
+ try {
5863
+ return { ok: true, record: JSON.parse(fs.readFileSync(storedPath, 'utf8')) };
5864
+ } catch (err) {
5865
+ return { ok: false, message: `stored scan at ${storedPath} is corrupt: ${err.message}` };
5866
+ }
5867
+ }
5868
+ return { ok: false, message: `no stored scan for digest ${target} — run "image scan" on the archive first` };
5869
+ }
5870
+ const read = _readFileOrError(target);
5871
+ if (!read.ok) return read;
5872
+ const scanned = await _scanArchiveFresh(read.buf);
5873
+ if (!scanned.ok) return scanned;
5874
+ return { ok: true, record: scanned.record };
5875
+ }
5876
+
5877
+ async function cmdImageScan(args) {
5878
+ const target = args._[2];
5879
+ if (!target) {
5880
+ process.stderr.write('agentic-security image scan: provide a path to a docker-archive or oci-archive tar (OCI tar / Docker tar — see commands/image.md for the two supported input shapes).\n');
5881
+ return 2;
5882
+ }
5883
+ const format = args.flags.format ?? 'json';
5884
+ if (format !== 'json' && format !== 'cli') {
5885
+ process.stderr.write('agentic-security image scan: --format must be json|cli.\n');
5886
+ return 2;
5887
+ }
5888
+ const read = _readFileOrError(target);
5889
+ if (!read.ok) { process.stderr.write(`agentic-security image scan: ${read.message}\n`); return 1; }
5890
+
5891
+ const scanned = await _scanArchiveFresh(read.buf);
5892
+ if (!scanned.ok) { process.stderr.write(`agentic-security image scan: ${scanned.message}\n`); return 1; }
5893
+
5894
+ await _persistArtifactScan(scanned.record);
5895
+
5896
+ if (format === 'json') {
5897
+ process.stdout.write(JSON.stringify(scanned.record, null, 2) + '\n');
5898
+ return 0;
5899
+ }
5900
+ const { inventory, vulnerabilities, secrets } = scanned.record;
5901
+ const vulnCount = vulnerabilities.filter((v) => v.type === 'vulnerable_dep').length;
5902
+ process.stdout.write([
5903
+ `Image digest: ${inventory.digest} (${inventory.digestKind})`,
5904
+ `OS/architecture: ${inventory.os}/${inventory.architecture}`,
5905
+ `OS packages: ${inventory.osPackages.length}, application packages: ${inventory.appPackages.length}`,
5906
+ `Vulnerabilities (OSV): ${vulnCount}`,
5907
+ `Secrets deleted-but-recoverable from an earlier layer: ${secrets.deletedButRecoverable.length}`,
5908
+ inventory.materializationTruncated ? 'NOTE: some layer content exceeded the scan size cap and was skipped.' : '',
5909
+ ].filter(Boolean).join('\n') + '\n');
5910
+ return 0;
5911
+ }
5912
+
5913
+ async function cmdImageVerify(args) {
5914
+ const target = args._[2];
5915
+ if (!target) {
5916
+ process.stderr.write('agentic-security image verify: provide a path to a docker-archive or oci-archive tar, or a previously-scanned digest.\n');
5917
+ return 2;
5918
+ }
5919
+ const resolved = await _resolveArtifactScan(target);
5920
+ if (!resolved.ok) { process.stderr.write(`agentic-security image verify: ${resolved.message}\n`); return 1; }
5921
+ const { inventory } = resolved.record;
5922
+
5923
+ const { verifyImageSignature, verifyProvenanceAttestation } = await import('../src/artifact/signature.js');
5924
+
5925
+ let signature = null;
5926
+ let publicKeyPem = null;
5927
+ let attestation = null;
5928
+ if (typeof args.flags.signature === 'string') {
5929
+ const r = _readFileOrError(args.flags.signature);
5930
+ if (!r.ok) { process.stderr.write(`agentic-security image verify: --signature: ${r.message}\n`); return 1; }
5931
+ signature = r.buf;
5932
+ }
5933
+ if (typeof args.flags['public-key'] === 'string') {
5934
+ const r = _readFileOrError(args.flags['public-key']);
5935
+ if (!r.ok) { process.stderr.write(`agentic-security image verify: --public-key: ${r.message}\n`); return 1; }
5936
+ publicKeyPem = r.buf.toString('utf8');
5937
+ }
5938
+ if (typeof args.flags.attestation === 'string') {
5939
+ const r = _readFileOrError(args.flags.attestation);
5940
+ if (!r.ok) { process.stderr.write(`agentic-security image verify: --attestation: ${r.message}\n`); return 1; }
5941
+ try { attestation = JSON.parse(r.buf.toString('utf8')); } catch (err) {
5942
+ process.stderr.write(`agentic-security image verify: --attestation is not valid JSON: ${err.message}\n`);
5943
+ return 1;
5944
+ }
5945
+ }
5946
+
5947
+ const signatureResult = verifyImageSignature({ digest: inventory.digest, signature, publicKeyPem });
5948
+ const attestationResult = verifyProvenanceAttestation({ digest: inventory.digest, envelope: attestation, publicKeyPem });
5949
+
5950
+ const result = { digest: inventory.digest, signature: signatureResult, attestation: attestationResult };
5951
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
5952
+ // Exit non-zero only on a definite "invalid" — "missing"/"unavailable" are
5953
+ // honest reports of absent material, not a failed check, and must not be
5954
+ // conflated with a cryptographic failure a CI gate should actually block on.
5955
+ return (signatureResult.state === 'invalid' || attestationResult.state === 'invalid') ? 1 : 0;
5956
+ }
5957
+
5958
+ async function cmdProvenanceTrace(args) {
5959
+ const target = args._[2];
5960
+ if (!target) {
5961
+ process.stderr.write('agentic-security provenance trace: provide a path to an image archive, or a previously-scanned digest.\n');
5962
+ return 2;
5963
+ }
5964
+ const resolved = await _resolveArtifactScan(target);
5965
+ if (!resolved.ok) { process.stderr.write(`agentic-security provenance trace: ${resolved.message}\n`); return 1; }
5966
+ const { inventory, vulnerabilities } = resolved.record;
5967
+
5968
+ let deployment = null;
5969
+ if (typeof args.flags['deployment-id'] === 'string') {
5970
+ deployment = {
5971
+ id: args.flags['deployment-id'],
5972
+ environment: typeof args.flags['deployment-environment'] === 'string' ? args.flags['deployment-environment'] : null,
5973
+ digest: typeof args.flags['deployment-digest'] === 'string' ? args.flags['deployment-digest'] : null,
5974
+ evidence: 'operator-declared',
5975
+ };
5976
+ }
5977
+
5978
+ const { buildArtifactProvenanceGraph, traceArtifactProvenanceGraph } = await import('../src/artifact/provenance-graph.js');
5979
+ const artifactProvenanceGraph = buildArtifactProvenanceGraph({ inventory, vulnerabilities, deployment });
5980
+ const commitNode = artifactProvenanceGraph.nodes.find((n) => n.kind === 'gitCommit');
5981
+ const trace = traceArtifactProvenanceGraph(artifactProvenanceGraph, commitNode.id);
5982
+
5983
+ let mismatch = null;
5984
+ if (typeof args.flags.source === 'string') {
5985
+ const sourceRoot = path.resolve(args.flags.source);
5986
+ const lastScanPath = path.join(sourceRoot, '.agentic-security', 'last-scan.json');
5987
+ if (!fs.existsSync(lastScanPath)) {
5988
+ process.stderr.write(`agentic-security provenance trace: --source "${args.flags.source}" has no .agentic-security/last-scan.json — run "scan" there first.\n`);
5989
+ return 1;
5990
+ }
5991
+ const sourceScan = JSON.parse(fs.readFileSync(lastScanPath, 'utf8'));
5992
+ const { detectSourceArtifactMismatch } = await import('../src/artifact/mismatch.js');
5993
+ mismatch = detectSourceArtifactMismatch({
5994
+ sourceVulnerabilities: sourceScan.supplyChain || [],
5995
+ artifactVulnerabilities: vulnerabilities,
5996
+ artifactComponents: [...inventory.osPackages, ...inventory.appPackages],
5997
+ });
5998
+ }
5999
+
6000
+ const format = args.flags.format ?? 'json';
6001
+ if (format === 'json') {
6002
+ process.stdout.write(JSON.stringify({ artifactProvenanceGraph, trace, mismatch }, null, 2) + '\n');
6003
+ return 0;
6004
+ }
6005
+
6006
+ const lines = [`Artifact digest: ${inventory.digest}`, `Source commit: ${commitNode.known ? commitNode.sha : '(not declared — no org.opencontainers.image.revision label)'}`];
6007
+ if (mismatch) {
6008
+ lines.push('');
6009
+ if (mismatch.scenarioA.length > 0) {
6010
+ lines.push('SOURCE: FIXED');
6011
+ lines.push('DEPLOYED ARTIFACT: STILL VULNERABLE');
6012
+ lines.push('PRODUCTION EXPOSURE: PRESENT');
6013
+ lines.push(` ${mismatch.scenarioA.length} vulnerabilit${mismatch.scenarioA.length === 1 ? 'y' : 'ies'} present in this artifact, already fixed in current source: ${mismatch.scenarioA.map((v) => v.osvId).join(', ')}`);
6014
+ }
6015
+ if (mismatch.scenarioB.length > 0) {
6016
+ lines.push(`${mismatch.scenarioA.length > 0 ? '' : 'SOURCE: VULNERABLE\n'}BUILT ARTIFACT: AFFECTED COMPONENT ABSENT`);
6017
+ lines.push(` ${mismatch.scenarioB.length} vulnerabilit${mismatch.scenarioB.length === 1 ? 'y' : 'ies'} flagged in current source did not actually ship in this artifact: ${mismatch.scenarioB.map((v) => v.osvId).join(', ')}`);
6018
+ }
6019
+ if (!mismatch.hasMismatch) lines.push('No mismatch: current source and this deployed artifact agree.');
6020
+ }
6021
+ process.stdout.write(lines.join('\n') + '\n');
6022
+ return 0;
6023
+ }
6024
+
6025
+ async function cmdSupplyVex(args) {
6026
+ const target = args._[2];
6027
+ if (!target) {
6028
+ process.stderr.write('agentic-security supply vex: provide a path to an image archive, or a previously-scanned digest.\n');
6029
+ return 2;
6030
+ }
6031
+ const resolved = await _resolveArtifactScan(target);
6032
+ if (!resolved.ok) { process.stderr.write(`agentic-security supply vex: ${resolved.message}\n`); return 1; }
6033
+ const { inventory, vulnerabilities } = resolved.record;
6034
+
6035
+ let reachabilityByKey = new Map();
6036
+ if (typeof args.flags.source === 'string') {
6037
+ const sourceRoot = path.resolve(args.flags.source);
6038
+ const lastScanPath = path.join(sourceRoot, '.agentic-security', 'last-scan.json');
6039
+ if (fs.existsSync(lastScanPath)) {
6040
+ const sourceScan = JSON.parse(fs.readFileSync(lastScanPath, 'utf8'));
6041
+ for (const sc of (sourceScan.supplyChain || [])) {
6042
+ if (sc.type !== 'vulnerable_dep') continue;
6043
+ const key = sc.purl || `${sc.ecosystem}:${sc.name}`;
6044
+ const reachable = sc.functionReachable === 'reachable' ? true : sc.functionReachable === 'unreachable' ? false : null;
6045
+ if (reachable !== null) reachabilityByKey.set(key, { reachable, reason: sc.reachabilityTier || null });
6046
+ }
6047
+ }
6048
+ }
6049
+
6050
+ const { deriveVexStatus } = await import('../src/artifact/vex.js');
6051
+ const artifactComponents = [...inventory.osPackages, ...inventory.appPackages];
6052
+ const statements = vulnerabilities.filter((v) => v.type === 'vulnerable_dep').map((v) => {
6053
+ const key = v.purl || `${v.ecosystem}:${v.name}`;
6054
+ const reachability = reachabilityByKey.get(key) || null;
6055
+ const vex = deriveVexStatus({ vulnerability: v, artifactComponents, reachability });
6056
+ return { purl: v.purl || null, name: v.name, ecosystem: v.ecosystem, osvId: v.osvId, ...vex };
6057
+ });
6058
+
6059
+ const format = args.flags.format ?? 'json';
6060
+ if (format === 'json') {
6061
+ process.stdout.write(JSON.stringify({ digest: inventory.digest, statements }, null, 2) + '\n');
6062
+ return 0;
6063
+ }
6064
+ const lines = [`VEX for ${inventory.digest}:`];
6065
+ for (const s of statements) lines.push(` ${s.osvId} (${s.name}): ${s.status}${s.justification ? ` [${s.justification}]` : ''}`);
6066
+ process.stdout.write(lines.join('\n') + '\n');
6067
+ return 0;
6068
+ }
6069
+
5781
6070
  // Mirrors posture/fix-history.js's own _writeAtomicAndSync (temp file in
5782
6071
  // the same directory, fsync, then rename over the target) — a crash
5783
6072
  // mid-write must never leave recipient-profiles.json as invalid JSON,
@@ -7524,6 +7813,48 @@ async function main() {
7524
7813
  }
7525
7814
  break;
7526
7815
  }
7816
+ case 'image': {
7817
+ // PRD 5 (Source-to-Artifact Security & Provenance) names this
7818
+ // literal CLI shape. `scan` parses + correlates; `verify` checks
7819
+ // signature/attestation material only (never re-runs correlation).
7820
+ const sub = args._[1];
7821
+ if (sub === 'scan') { process.exit(await cmdImageScan(args)); }
7822
+ else if (sub === 'verify') { process.exit(await cmdImageVerify(args)); }
7823
+ else {
7824
+ process.stderr.write(`agentic-security image: unrecognized sub-command "${sub}" — must be "scan" or "verify".\n`);
7825
+ process.exit(2);
7826
+ }
7827
+ break;
7828
+ }
7829
+ case 'provenance': {
7830
+ // NOT `posture/provenance/`'s git-commit finding provenance, NOT
7831
+ // `sca/sigstore-verify.js`'s package-build provenance — this is PRD
7832
+ // 5's own artifact provenance graph (Git Commit -> Build -> OCI
7833
+ // Digest -> SBOM Component -> Finding -> Deployment). See
7834
+ // docs/implementation-status/source-to-artifact-provenance.md's
7835
+ // naming-discipline section.
7836
+ const sub = args._[1];
7837
+ if (sub === 'trace') { process.exit(await cmdProvenanceTrace(args)); }
7838
+ else {
7839
+ process.stderr.write(`agentic-security provenance: unrecognized sub-command "${sub}" — must be "trace".\n`);
7840
+ process.exit(2);
7841
+ }
7842
+ break;
7843
+ }
7844
+ case 'supply': {
7845
+ // The `/supply` SLASH COMMAND (commands/supply.md) is a different
7846
+ // layer entirely — Claude Code UI sugar that shells out to `scan
7847
+ // --only sca`/`cve-watch`. This is PRD 5's own literal CLI shape,
7848
+ // `agentic-security supply vex`, operating on an IMAGE ARTIFACT
7849
+ // rather than a source directory.
7850
+ const sub = args._[1];
7851
+ if (sub === 'vex') { process.exit(await cmdSupplyVex(args)); }
7852
+ else {
7853
+ process.stderr.write(`agentic-security supply: unrecognized sub-command "${sub}" — must be "vex".\n`);
7854
+ process.exit(2);
7855
+ }
7856
+ break;
7857
+ }
7527
7858
  case 'remediation': {
7528
7859
  // NOT a `dataflow` subcommand — this writes operator/incident
7529
7860
  // state (the append-only remediation ledger), never the scanned
@@ -313,7 +313,7 @@ function saveProof(scanRoot, record) {
313
313
  const enc = (0,_encryption_provider_js__WEBPACK_IMPORTED_MODULE_3__/* .maybeEncryptForWrite */ .v2)(scanRoot, PROOFS_DIR, text);
314
314
  if (!enc.ok) return { ok: false, reason: enc.reason };
315
315
  const file = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.statePath)(scanRoot, PROOFS_DIR, findingKey(record.findingId), `${safe.recordDigest}.json`);
316
- if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__/* .safeWriteState */ .Ep)(file, enc.content)) return { ok: false, reason: 'state write refused' };
316
+ if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_2__.safeWriteState)(file, enc.content)) return { ok: false, reason: 'state write refused' };
317
317
  return { ok: true, file, digest: safe.recordDigest, encrypted: enc.encrypted };
318
318
  }
319
319
 
@@ -0,0 +1,174 @@
1
+ export const id = 2675;
2
+ export const ids = [2675];
3
+ export const modules = {
4
+
5
+ /***/ 82675:
6
+ /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
7
+
8
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
9
+ /* harmony export */ verifyImageSignature: () => (/* binding */ verifyImageSignature),
10
+ /* harmony export */ verifyProvenanceAttestation: () => (/* binding */ verifyProvenanceAttestation)
11
+ /* harmony export */ });
12
+ /* unused harmony export SIGNATURE_STATES */
13
+ /* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(77598);
14
+ // PRD 5 P0 §5 — Signature Verification. States are exactly the PRD's own
15
+ // four, deliberately different from `sca/sigstore-verify.js`'s own vocabulary
16
+ // (`verified/unverified/tampered/unknown`) so the two are never confused by
17
+ // shared words — see docs/implementation-status/source-to-artifact-provenance.md's
18
+ // naming-discipline section:
19
+ //
20
+ // verified — a real cryptographic check ran and the signature matches.
21
+ // invalid — a real cryptographic check ran and the signature does NOT match.
22
+ // missing — no signature/attestation material was supplied at all.
23
+ // unavailable — material was supplied but could not be cryptographically
24
+ // checked (no public key given, or the check itself could
25
+ // not run) — the PRD's "never treat missing tooling as
26
+ // verified" clause means this state, not `verified`, is what
27
+ // an incomplete check must report.
28
+ //
29
+ // Two real, independent mechanisms are implemented:
30
+ // 1. A plain detached signature over the image digest string, verified
31
+ // with an operator-supplied public key. This is this tool's OWN local
32
+ // convention, not cosign's "simple signing" JSON format — disclosed in
33
+ // the implementation-status doc, not silently passed off as cosign-
34
+ // compatible.
35
+ // 2. Real DSSE (Dead Simple Signing Envelope) verification for in-toto/SLSA
36
+ // provenance attestations — the actual encoding cosign/Sigstore use for
37
+ // attestations, so a real cosign-keyed attestation bundle DOES verify
38
+ // here given the matching public key. `dssePae` is the exact PAE
39
+ // (Pre-Authentication Encoding) from the DSSE spec.
40
+ //
41
+ // Keyless/Rekor-transparency-log verification is NOT implemented — disclosed
42
+ // as a gap, never faked. `sca/sigstore-verify.js`'s Rekor-query pattern is
43
+ // the template a future increment could extend this module with.
44
+
45
+
46
+ const SIGNATURE_STATES = Object.freeze(['verified', 'invalid', 'missing', 'unavailable']);
47
+
48
+ function verifyRaw(payload, signature, publicKeyPem) {
49
+ let publicKey;
50
+ try {
51
+ publicKey = node_crypto__WEBPACK_IMPORTED_MODULE_0__.createPublicKey(publicKeyPem);
52
+ } catch (err) {
53
+ return { ok: false, error: `public key is not parseable: ${err.message}` };
54
+ }
55
+ const keyType = publicKey.asymmetricKeyType;
56
+ const algorithm = (keyType === 'ed25519' || keyType === 'ed448') ? null : 'sha256';
57
+ try {
58
+ const ok = node_crypto__WEBPACK_IMPORTED_MODULE_0__.verify(algorithm, payload, publicKey, signature);
59
+ return { ok, keyType };
60
+ } catch (err) {
61
+ return { ok: false, error: `signature verification failed to run: ${err.message}` };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Verifies a detached signature over an image's digest string.
67
+ * `signature`/`publicKeyPem` are Buffer/PEM-string; either missing means
68
+ * `missing` (nothing was supplied to check).
69
+ */
70
+ function verifyImageSignature({ digest, signature, publicKeyPem }) {
71
+ if (typeof digest !== 'string' || !digest.startsWith('sha256:')) {
72
+ return { state: 'unavailable', reason: 'no image digest available to verify a signature against' };
73
+ }
74
+ if (!signature || !publicKeyPem) {
75
+ return { state: 'missing', reason: 'no detached signature and public key were supplied for this image' };
76
+ }
77
+ const { ok, error, keyType } = verifyRaw(Buffer.from(digest, 'utf8'), signature, publicKeyPem);
78
+ if (error) return { state: 'invalid', reason: error };
79
+ return ok
80
+ ? { state: 'verified', keyType }
81
+ : { state: 'invalid', reason: 'signature does not match the image digest under the supplied public key' };
82
+ }
83
+
84
+ // DSSE Pre-Authentication Encoding: "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body.
85
+ function dssePae(payloadType, payloadBytes) {
86
+ const typeBytes = Buffer.from(payloadType, 'utf8');
87
+ return Buffer.concat([
88
+ Buffer.from('DSSEv1 ', 'ascii'),
89
+ Buffer.from(String(typeBytes.length), 'ascii'),
90
+ Buffer.from(' ', 'ascii'),
91
+ typeBytes,
92
+ Buffer.from(' ', 'ascii'),
93
+ Buffer.from(String(payloadBytes.length), 'ascii'),
94
+ Buffer.from(' ', 'ascii'),
95
+ payloadBytes,
96
+ ]);
97
+ }
98
+
99
+ const SLSA_PREDICATE_PREFIX = 'https://slsa.dev/provenance/';
100
+ const INTOTO_STATEMENT_TYPES = new Set(['https://in-toto.io/Statement/v0.1', 'https://in-toto.io/Statement/v1']);
101
+
102
+ /**
103
+ * Verifies an in-toto/SLSA provenance attestation for an image.
104
+ * `envelope` is a DSSE envelope: `{ payloadType, payload (base64 in-toto
105
+ * Statement JSON), signatures: [{ sig (base64) }] }` — the real shape
106
+ * cosign/Sigstore attestations use. `publicKeyPem` verifies one of
107
+ * `signatures` against `dssePae(payloadType, payload)`.
108
+ *
109
+ * The subject-digest match is checked FIRST and independently of signature
110
+ * verification, so a mismatched subject is always `invalid` (never
111
+ * "unavailable", which would understate a real, checkable problem).
112
+ */
113
+ function verifyProvenanceAttestation({ digest, envelope, publicKeyPem }) {
114
+ if (!envelope) return { state: 'missing', reason: 'no in-toto/SLSA provenance attestation was supplied for this image' };
115
+
116
+ let env;
117
+ try {
118
+ env = typeof envelope === 'string' ? JSON.parse(envelope) : envelope;
119
+ } catch (err) {
120
+ return { state: 'invalid', reason: `attestation envelope is not valid JSON: ${err.message}` };
121
+ }
122
+ if (typeof env.payload !== 'string' || typeof env.payloadType !== 'string' || !Array.isArray(env.signatures)) {
123
+ return { state: 'invalid', reason: 'not a recognized DSSE envelope (missing payload, payloadType, or signatures)' };
124
+ }
125
+
126
+ let payloadBytes;
127
+ let stmt;
128
+ try {
129
+ payloadBytes = Buffer.from(env.payload, 'base64');
130
+ stmt = JSON.parse(payloadBytes.toString('utf8'));
131
+ } catch (err) {
132
+ return { state: 'invalid', reason: `attestation payload is not a valid base64-encoded JSON statement: ${err.message}` };
133
+ }
134
+ if (!INTOTO_STATEMENT_TYPES.has(stmt._type)) {
135
+ return { state: 'invalid', reason: 'statement is missing a recognized in-toto "_type"' };
136
+ }
137
+
138
+ const digestHex = typeof digest === 'string' && digest.startsWith('sha256:') ? digest.slice('sha256:'.length) : digest;
139
+ const subjects = Array.isArray(stmt.subject) ? stmt.subject : [];
140
+ const subjectMatches = subjects.some((s) => s && s.digest && s.digest.sha256 === digestHex);
141
+ if (!subjectMatches) {
142
+ return { state: 'invalid', reason: 'attestation subject digest does not match this image', predicateType: stmt.predicateType || null };
143
+ }
144
+
145
+ const predicateType = stmt.predicateType || null;
146
+ const isSlsa = typeof predicateType === 'string' && predicateType.startsWith(SLSA_PREDICATE_PREFIX);
147
+
148
+ if (!publicKeyPem || env.signatures.length === 0) {
149
+ return {
150
+ state: 'unavailable',
151
+ reason: 'attestation subject matches this image, but no public key was supplied to verify its signature',
152
+ predicateType, isSlsa,
153
+ };
154
+ }
155
+
156
+ const pae = dssePae(env.payloadType, payloadBytes);
157
+ for (const sigEntry of env.signatures) {
158
+ if (!sigEntry || typeof sigEntry.sig !== 'string') continue;
159
+ let sigBytes;
160
+ try {
161
+ sigBytes = Buffer.from(sigEntry.sig, 'base64');
162
+ } catch {
163
+ continue;
164
+ }
165
+ const { ok } = verifyRaw(pae, sigBytes, publicKeyPem);
166
+ if (ok) return { state: 'verified', predicateType, isSlsa };
167
+ }
168
+ return { state: 'invalid', reason: 'no signature in the attestation envelope verifies under the supplied public key', predicateType, isSlsa };
169
+ }
170
+
171
+
172
+ /***/ })
173
+
174
+ };
@@ -712,13 +712,13 @@ function loadLedger(scanRoot) {
712
712
  function saveLedger(scanRoot, ledger) {
713
713
  if (!(0,state_dir/* ensureStateDir */.xH)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
714
714
  const f = (0,state_dir.statePath)(scanRoot, DIR, 'ledger.json');
715
- return { ok: (0,state_dir/* safeWriteState */.Ep)(f, JSON.stringify(ledger, null, 2)) };
715
+ return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(ledger, null, 2)) };
716
716
  }
717
717
 
718
718
  function saveIR(scanRoot, ir) {
719
719
  if (!(0,state_dir/* ensureStateDir */.xH)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
720
720
  const f = external_node_path_.join(ruleDir(scanRoot, ir.id), 'ir.json');
721
- return { ok: (0,state_dir/* safeWriteState */.Ep)(f, JSON.stringify(ir, null, 2)), file: f };
721
+ return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(ir, null, 2)), file: f };
722
722
  }
723
723
 
724
724
  function loadIR(scanRoot, id) {
@@ -729,7 +729,7 @@ function loadIR(scanRoot, id) {
729
729
  function saveEvaluation(scanRoot, id, evaluation) {
730
730
  if (!(0,state_dir/* ensureStateDir */.xH)(scanRoot)) return { ok: false, reason: 'no safe state directory for this project' };
731
731
  const f = external_node_path_.join(ruleDir(scanRoot, id), 'evaluation.json');
732
- return { ok: (0,state_dir/* safeWriteState */.Ep)(f, JSON.stringify(evaluation, null, 2)) };
732
+ return { ok: (0,state_dir.safeWriteState)(f, JSON.stringify(evaluation, null, 2)) };
733
733
  }
734
734
 
735
735
  /** Write the regression fixtures a promoted rule needs: the SAME vulnerable/clean convention `runRuleTests`
@@ -741,11 +741,11 @@ function writeFixtures(scanRoot, ir, corpus) {
741
741
  const written = [];
742
742
  for (const p of corpus.positives) {
743
743
  const f = external_node_path_.join(base, 'vulnerable', `${p.label.replace(/[^a-z0-9_-]/gi, '_')}.${ext}`);
744
- if ((0,state_dir/* safeWriteState */.Ep)(f, p.code)) written.push(f);
744
+ if ((0,state_dir.safeWriteState)(f, p.code)) written.push(f);
745
745
  }
746
746
  for (const n of corpus.negatives) {
747
747
  const f = external_node_path_.join(base, 'clean', `${n.label.replace(/[^a-z0-9_-]/gi, '_')}.${ext}`);
748
- if ((0,state_dir/* safeWriteState */.Ep)(f, n.code)) written.push(f);
748
+ if ((0,state_dir.safeWriteState)(f, n.code)) written.push(f);
749
749
  }
750
750
  return { ok: true, written, glob: external_node_path_.join(base, '**', '*') };
751
751
  }
@@ -772,7 +772,7 @@ function publishCustomRule(scanRoot, ir) {
772
772
  ` message: ${JSON.stringify(doc.message)}`,
773
773
  '',
774
774
  ].join('\n');
775
- return { ok: (0,state_dir/* safeWriteState */.Ep)(f, yamlText), file: f };
775
+ return { ok: (0,state_dir.safeWriteState)(f, yamlText), file: f };
776
776
  }
777
777
 
778
778
  function unpublishCustomRule(scanRoot, id) {
@@ -136,7 +136,7 @@ function addLegalHold(scanRoot, { artifact, owner, reason, expires_at } = {}) {
136
136
  const holds = _loadRaw(scanRoot);
137
137
  holds.push(hold);
138
138
  const fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.statePath)(scanRoot, LEGAL_HOLD_FILE);
139
- if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__/* .safeWriteState */ .Ep)(fp, JSON.stringify(holds, null, 2) + '\n')) {
139
+ if (!(0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.safeWriteState)(fp, JSON.stringify(holds, null, 2) + '\n')) {
140
140
  return { ok: false, reason: 'state writes are disabled (--no-state) or this is not a safe state directory' };
141
141
  }
142
142
  return { ok: true, hold };
@@ -152,7 +152,7 @@ function removeLegalHold(scanRoot, artifact) {
152
152
  const removedCount = holds.length - remaining.length;
153
153
  if (removedCount > 0) {
154
154
  const fp = (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.statePath)(scanRoot, LEGAL_HOLD_FILE);
155
- (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__/* .safeWriteState */ .Ep)(fp, JSON.stringify(remaining, null, 2) + '\n');
155
+ (0,_state_dir_js__WEBPACK_IMPORTED_MODULE_1__.safeWriteState)(fp, JSON.stringify(remaining, null, 2) + '\n');
156
156
  }
157
157
  return removedCount;
158
158
  }